use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub trait IntoContent {
fn into_content(self) -> Value;
}
impl IntoContent for &str {
fn into_content(self) -> Value {
Value::String(self.to_owned())
}
}
impl IntoContent for String {
fn into_content(self) -> Value {
Value::String(self)
}
}
impl IntoContent for &String {
fn into_content(self) -> Value {
Value::String(self.clone())
}
}
impl IntoContent for Value {
fn into_content(self) -> Value {
self
}
}
impl IntoContent for &Value {
fn into_content(self) -> Value {
self.clone()
}
}
pub trait IntoDescription {
fn into_description(self) -> Option<String>;
}
impl IntoDescription for &str {
fn into_description(self) -> Option<String> {
Some(self.to_owned())
}
}
impl IntoDescription for String {
fn into_description(self) -> Option<String> {
Some(self)
}
}
impl IntoDescription for Option<&str> {
fn into_description(self) -> Option<String> {
self.map(str::to_owned)
}
}
impl IntoDescription for Option<String> {
fn into_description(self) -> Option<String> {
self
}
}
impl IntoDescription for Option<&String> {
fn into_description(self) -> Option<String> {
self.cloned()
}
}
impl IntoDescription for () {
fn into_description(self) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Question {
Noul(Noul),
Choice(Choice),
Score(Score),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Noul {
pub instructions: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub criteria: Option<NoulCriteria>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NoulCriteria {
#[serde(rename = "true", skip_serializing_if = "Option::is_none")]
pub yes: Option<String>,
#[serde(rename = "false", skip_serializing_if = "Option::is_none")]
pub no: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub instructions: Value,
pub criteria: IndexMap<String, Option<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Score {
pub instructions: Value,
pub criteria: Vec<String>,
}
impl Noul {
pub fn new(instructions: impl IntoContent) -> Self {
Self {
instructions: instructions.into_content(),
criteria: None,
}
}
pub fn criteria(mut self, criteria: NoulCriteria) -> Self {
self.criteria = Some(criteria);
self
}
}
impl NoulCriteria {
pub fn new(yes: impl Into<String>, no: impl Into<String>) -> Self {
Self {
yes: Some(yes.into()),
no: Some(no.into()),
}
}
pub fn yes(yes: impl Into<String>) -> Self {
Self {
yes: Some(yes.into()),
no: None,
}
}
pub fn no(no: impl Into<String>) -> Self {
Self {
yes: None,
no: Some(no.into()),
}
}
}
impl Choice {
pub fn new<K, D, I>(instructions: impl IntoContent, criteria: I) -> Self
where
K: Into<String>,
D: IntoDescription,
I: IntoIterator<Item = (K, D)>,
{
Self {
instructions: instructions.into_content(),
criteria: criteria
.into_iter()
.map(|(k, d)| (k.into(), d.into_description()))
.collect(),
}
}
pub fn options<I, S>(instructions: impl IntoContent, options: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
instructions: instructions.into_content(),
criteria: options.into_iter().map(|o| (o.into(), None)).collect(),
}
}
}
impl Score {
pub fn new<I, S>(instructions: impl IntoContent, criteria: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
instructions: instructions.into_content(),
criteria: criteria.into_iter().map(Into::into).collect(),
}
}
}
impl From<Noul> for Question {
fn from(q: Noul) -> Self {
Question::Noul(q)
}
}
impl From<Choice> for Question {
fn from(q: Choice) -> Self {
Question::Choice(q)
}
}
impl From<Score> for Question {
fn from(q: Score) -> Self {
Question::Score(q)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn noul_serializes_with_tag() {
let q = Question::from(Noul::new("Is this urgent?"));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({"type": "noul", "instructions": "Is this urgent?"})
);
}
#[test]
fn noul_criteria_uses_true_false_keys() {
let q = Question::from(
Noul::new("Urgent?").criteria(NoulCriteria::new("Time-sensitive", "No urgency")),
);
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({
"type": "noul",
"instructions": "Urgent?",
"criteria": {"true": "Time-sensitive", "false": "No urgency"},
})
);
}
#[test]
fn choice_serializes_with_null_descriptions() {
let q = Question::from(Choice::new(
"Tone?",
[("calm", None), ("angry", Some("Strong language"))],
));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({
"type": "choice",
"instructions": "Tone?",
"criteria": {"calm": null, "angry": "Strong language"},
})
);
}
#[test]
fn score_serializes_ordered_levels() {
let q = Question::from(Score::new("Frustration?", ["Calm", "Frustrated", "Angry"]));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({
"type": "score",
"instructions": "Frustration?",
"criteria": ["Calm", "Frustrated", "Angry"],
})
);
}
#[test]
fn structured_instructions_are_supported() {
let q = Question::from(Noul::new(
json!({"question": "Urgent?", "context": {"n": 1}}),
));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v["instructions"]["question"], "Urgent?");
}
#[test]
fn questions_deserialize_from_api_json() {
let raw = json!({
"type": "choice",
"instructions": "Which path?",
"criteria": {"a": "Fast path", "b": null},
});
let q: Question = serde_json::from_value(raw).unwrap();
let Question::Choice(c) = &q else {
panic!("expected choice, got {q:?}");
};
assert_eq!(c.instructions, json!("Which path?"));
assert_eq!(c.criteria["a"], Some("Fast path".to_owned()));
assert_eq!(c.criteria["b"], None);
let keys: Vec<_> = c.criteria.keys().collect();
assert_eq!(keys, ["a", "b"]);
}
#[test]
fn questions_round_trip_through_json() {
let questions = [
Question::from(Noul::new("Will it work?").criteria(NoulCriteria::new("Yes", "No"))),
Question::from(Choice::new("Pick", [("x", None), ("y", Some("Why y"))])),
Question::from(Score::new("Rate", ["low", "mid", "high"])),
];
for q in questions {
let v = serde_json::to_value(&q).unwrap();
let back: Question = serde_json::from_value(v.clone()).unwrap();
assert_eq!(serde_json::to_value(&back).unwrap(), v);
}
}
#[test]
fn deserialize_rejects_unknown_type() {
let raw = json!({"type": "maybe", "instructions": "?"});
assert!(serde_json::from_value::<Question>(raw).is_err());
}
#[test]
fn noul_criteria_partial_round_trip() {
let raw = json!({"type": "noul", "instructions": "Q?", "criteria": {"true": "yes means"}});
let q: Question = serde_json::from_value(raw).unwrap();
let Question::Noul(n) = &q else {
panic!("expected noul, got {q:?}");
};
let criteria = n.criteria.as_ref().unwrap();
assert_eq!(criteria.yes.as_deref(), Some("yes means"));
assert_eq!(criteria.no, None);
}
}