1use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15pub trait IntoContent {
20 fn into_content(self) -> Value;
22}
23
24impl IntoContent for &str {
25 fn into_content(self) -> Value {
26 Value::String(self.to_owned())
27 }
28}
29
30impl IntoContent for String {
31 fn into_content(self) -> Value {
32 Value::String(self)
33 }
34}
35
36impl IntoContent for &String {
37 fn into_content(self) -> Value {
38 Value::String(self.clone())
39 }
40}
41
42impl IntoContent for Value {
43 fn into_content(self) -> Value {
44 self
45 }
46}
47
48impl IntoContent for &Value {
49 fn into_content(self) -> Value {
50 self.clone()
51 }
52}
53
54pub trait IntoDescription {
59 fn into_description(self) -> Option<String>;
61}
62
63impl IntoDescription for &str {
64 fn into_description(self) -> Option<String> {
65 Some(self.to_owned())
66 }
67}
68
69impl IntoDescription for String {
70 fn into_description(self) -> Option<String> {
71 Some(self)
72 }
73}
74
75impl IntoDescription for Option<&str> {
76 fn into_description(self) -> Option<String> {
77 self.map(str::to_owned)
78 }
79}
80
81impl IntoDescription for Option<String> {
82 fn into_description(self) -> Option<String> {
83 self
84 }
85}
86
87impl IntoDescription for Option<&String> {
88 fn into_description(self) -> Option<String> {
89 self.cloned()
90 }
91}
92
93impl IntoDescription for () {
94 fn into_description(self) -> Option<String> {
95 None
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "lowercase")]
108pub enum Question {
109 Noul(Noul),
111 Choice(Choice),
113 Score(Score),
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Noul {
120 pub instructions: Value,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub criteria: Option<NoulCriteria>,
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129pub struct NoulCriteria {
130 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
132 pub yes: Option<String>,
133 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
135 pub no: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct Choice {
141 pub instructions: Value,
143 pub criteria: IndexMap<String, Option<String>>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Score {
151 pub instructions: Value,
153 pub criteria: Vec<String>,
155}
156
157impl Noul {
158 pub fn new(instructions: impl IntoContent) -> Self {
160 Self {
161 instructions: instructions.into_content(),
162 criteria: None,
163 }
164 }
165
166 pub fn criteria(mut self, criteria: NoulCriteria) -> Self {
168 self.criteria = Some(criteria);
169 self
170 }
171}
172
173impl NoulCriteria {
174 pub fn new(yes: impl Into<String>, no: impl Into<String>) -> Self {
176 Self {
177 yes: Some(yes.into()),
178 no: Some(no.into()),
179 }
180 }
181
182 pub fn yes(yes: impl Into<String>) -> Self {
184 Self {
185 yes: Some(yes.into()),
186 no: None,
187 }
188 }
189
190 pub fn no(no: impl Into<String>) -> Self {
192 Self {
193 yes: None,
194 no: Some(no.into()),
195 }
196 }
197}
198
199impl Choice {
200 pub fn new<K, D, I>(instructions: impl IntoContent, criteria: I) -> Self
212 where
213 K: Into<String>,
214 D: IntoDescription,
215 I: IntoIterator<Item = (K, D)>,
216 {
217 Self {
218 instructions: instructions.into_content(),
219 criteria: criteria
220 .into_iter()
221 .map(|(k, d)| (k.into(), d.into_description()))
222 .collect(),
223 }
224 }
225
226 pub fn options<I, S>(instructions: impl IntoContent, options: I) -> Self
236 where
237 I: IntoIterator<Item = S>,
238 S: Into<String>,
239 {
240 Self {
241 instructions: instructions.into_content(),
242 criteria: options.into_iter().map(|o| (o.into(), None)).collect(),
243 }
244 }
245}
246
247impl Score {
248 pub fn new<I, S>(instructions: impl IntoContent, criteria: I) -> Self
253 where
254 I: IntoIterator<Item = S>,
255 S: Into<String>,
256 {
257 Self {
258 instructions: instructions.into_content(),
259 criteria: criteria.into_iter().map(Into::into).collect(),
260 }
261 }
262}
263
264impl From<Noul> for Question {
265 fn from(q: Noul) -> Self {
266 Question::Noul(q)
267 }
268}
269
270impl From<Choice> for Question {
271 fn from(q: Choice) -> Self {
272 Question::Choice(q)
273 }
274}
275
276impl From<Score> for Question {
277 fn from(q: Score) -> Self {
278 Question::Score(q)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use serde_json::json;
286
287 #[test]
288 fn noul_serializes_with_tag() {
289 let q = Question::from(Noul::new("Is this urgent?"));
290 let v = serde_json::to_value(&q).unwrap();
291 assert_eq!(
292 v,
293 json!({"type": "noul", "instructions": "Is this urgent?"})
294 );
295 }
296
297 #[test]
298 fn noul_criteria_uses_true_false_keys() {
299 let q = Question::from(
300 Noul::new("Urgent?").criteria(NoulCriteria::new("Time-sensitive", "No urgency")),
301 );
302 let v = serde_json::to_value(&q).unwrap();
303 assert_eq!(
304 v,
305 json!({
306 "type": "noul",
307 "instructions": "Urgent?",
308 "criteria": {"true": "Time-sensitive", "false": "No urgency"},
309 })
310 );
311 }
312
313 #[test]
314 fn choice_serializes_with_null_descriptions() {
315 let q = Question::from(Choice::new(
316 "Tone?",
317 [("calm", None), ("angry", Some("Strong language"))],
318 ));
319 let v = serde_json::to_value(&q).unwrap();
320 assert_eq!(
321 v,
322 json!({
323 "type": "choice",
324 "instructions": "Tone?",
325 "criteria": {"calm": null, "angry": "Strong language"},
326 })
327 );
328 }
329
330 #[test]
331 fn score_serializes_ordered_levels() {
332 let q = Question::from(Score::new("Frustration?", ["Calm", "Frustrated", "Angry"]));
333 let v = serde_json::to_value(&q).unwrap();
334 assert_eq!(
335 v,
336 json!({
337 "type": "score",
338 "instructions": "Frustration?",
339 "criteria": ["Calm", "Frustrated", "Angry"],
340 })
341 );
342 }
343
344 #[test]
345 fn structured_instructions_are_supported() {
346 let q = Question::from(Noul::new(
347 json!({"question": "Urgent?", "context": {"n": 1}}),
348 ));
349 let v = serde_json::to_value(&q).unwrap();
350 assert_eq!(v["instructions"]["question"], "Urgent?");
351 }
352
353 #[test]
354 fn questions_deserialize_from_api_json() {
355 let raw = json!({
357 "type": "choice",
358 "instructions": "Which path?",
359 "criteria": {"a": "Fast path", "b": null},
360 });
361 let q: Question = serde_json::from_value(raw).unwrap();
362 let Question::Choice(c) = &q else {
363 panic!("expected choice, got {q:?}");
364 };
365 assert_eq!(c.instructions, json!("Which path?"));
366 assert_eq!(c.criteria["a"], Some("Fast path".to_owned()));
367 assert_eq!(c.criteria["b"], None);
368 let keys: Vec<_> = c.criteria.keys().collect();
370 assert_eq!(keys, ["a", "b"]);
371 }
372
373 #[test]
374 fn questions_round_trip_through_json() {
375 let questions = [
376 Question::from(Noul::new("Will it work?").criteria(NoulCriteria::new("Yes", "No"))),
377 Question::from(Choice::new("Pick", [("x", None), ("y", Some("Why y"))])),
378 Question::from(Score::new("Rate", ["low", "mid", "high"])),
379 ];
380 for q in questions {
381 let v = serde_json::to_value(&q).unwrap();
382 let back: Question = serde_json::from_value(v.clone()).unwrap();
383 assert_eq!(serde_json::to_value(&back).unwrap(), v);
384 }
385 }
386
387 #[test]
388 fn deserialize_rejects_unknown_type() {
389 let raw = json!({"type": "maybe", "instructions": "?"});
390 assert!(serde_json::from_value::<Question>(raw).is_err());
391 }
392
393 #[test]
394 fn noul_criteria_partial_round_trip() {
395 let raw = json!({"type": "noul", "instructions": "Q?", "criteria": {"true": "yes means"}});
396 let q: Question = serde_json::from_value(raw).unwrap();
397 let Question::Noul(n) = &q else {
398 panic!("expected noul, got {q:?}");
399 };
400 let criteria = n.criteria.as_ref().unwrap();
401 assert_eq!(criteria.yes.as_deref(), Some("yes means"));
402 assert_eq!(criteria.no, None);
403 }
404}