use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum QuestionType {
ChooseOne,
ChooseMany,
FreeWrite,
YesNo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PreviewType {
Markdown,
Ascii,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Question {
pub id: String,
#[serde(rename = "type")]
pub question_type: QuestionType,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<PreviewBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewBlock {
#[serde(rename = "type")]
pub preview_type: PreviewType,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QOutFile {
pub seq: u32,
pub questions: Vec<Question>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AnswerValue {
Text(String),
MultiSelect(Vec<String>),
Boolean(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Answer {
pub id: String,
#[serde(rename = "type")]
pub question_type: QuestionType,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
pub answer: AnswerValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QAnswersFile {
pub seq: u32,
pub answers: Vec<Answer>,
pub questions: Vec<Question>,
}
pub fn format_seq(seq: u32) -> String {
format!("{:03}", seq)
}
pub fn parse_seq_from_filename(filename: &str) -> Option<u32> {
let rest = if filename.starts_with("q-out-") {
&filename[6..]
} else if filename.starts_with("q-answers-") {
&filename[10..]
} else {
return None;
};
let rest = rest.strip_suffix(".json")?;
if rest.len() != 3 || !rest.chars().all(|c| c.is_ascii_digit()) {
return None;
}
rest.parse::<u32>().ok()
}
pub fn question_to_answer(question: &Question, answer: AnswerValue) -> Answer {
Answer {
id: question.id.clone(),
question_type: question.question_type.clone(),
prompt: question.prompt.clone(),
options: question.options.clone(),
answer,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_seq() {
assert_eq!(format_seq(1), "001");
assert_eq!(format_seq(42), "042");
assert_eq!(format_seq(123), "123");
}
#[test]
fn test_parse_seq_from_filename() {
assert_eq!(parse_seq_from_filename("q-out-001.json"), Some(1));
assert_eq!(parse_seq_from_filename("q-answers-042.json"), Some(42));
assert_eq!(parse_seq_from_filename("other.json"), None);
assert_eq!(parse_seq_from_filename("q-out-010.json"), Some(10)); }
#[test]
fn test_roundtrip_qout() {
let qout = QOutFile {
seq: 1,
questions: vec![
Question {
id: "q1".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick one".to_string(),
options: Some(vec!["a".to_string(), "b".to_string()]),
preview: Some(PreviewBlock {
preview_type: PreviewType::Markdown,
content: "## Preview".to_string(),
}),
},
Question {
id: "q2".to_string(),
question_type: QuestionType::FreeWrite,
prompt: "Describe".to_string(),
options: None,
preview: None,
},
],
};
let json = serde_json::to_string(&qout).unwrap();
let parsed: QOutFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.seq, 1);
assert_eq!(parsed.questions.len(), 2);
assert_eq!(parsed.questions[0].question_type, QuestionType::ChooseOne);
assert!(parsed.questions[0].preview.is_some());
assert!(parsed.questions[1].preview.is_none());
}
#[test]
fn test_roundtrip_answers() {
let questions = vec![
Question {
id: "q1".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick".to_string(),
options: Some(vec!["a".to_string(), "b".to_string()]),
preview: None,
},
Question {
id: "q2".to_string(),
question_type: QuestionType::YesNo,
prompt: "OK?".to_string(),
options: None,
preview: None,
},
];
let answers = QAnswersFile {
seq: 1,
answers: vec![
Answer {
id: "q1".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick".to_string(),
options: Some(vec!["a".to_string(), "b".to_string()]),
answer: AnswerValue::Text("a".to_string()),
},
Answer {
id: "q2".to_string(),
question_type: QuestionType::YesNo,
prompt: "OK?".to_string(),
options: None,
answer: AnswerValue::Boolean(true),
},
],
questions: questions.clone(),
};
let json = serde_json::to_string(&answers).unwrap();
let parsed: QAnswersFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.seq, 1);
assert_eq!(parsed.answers.len(), 2);
assert_eq!(parsed.questions.len(), 2);
assert_eq!(parsed.questions[0].id, "q1");
assert_eq!(parsed.questions[1].prompt, "OK?");
}
#[test]
fn test_answers_file_with_questions_roundtrip() {
let json = serde_json::json!({
"seq": 2,
"answers": [
{"id": "q1", "type": "yes-no", "prompt": "Ready?", "answer": true}
],
"questions": [
{"id": "q1", "type": "yes-no", "prompt": "Ready?"}
]
})
.to_string();
let parsed: QAnswersFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.seq, 2);
assert_eq!(parsed.questions.len(), 1);
assert_eq!(parsed.questions[0].id, "q1");
}
#[test]
fn test_cross_impl_json_compat() {
let json = r#"{"seq":1,"questions":[{"id":"q1","type":"choose-one","prompt":"Pick","options":["a","b"]},{"id":"q2","type":"free-write","prompt":"Describe"}]}"#;
let qout: QOutFile = serde_json::from_str(json).unwrap();
assert_eq!(qout.seq, 1);
assert_eq!(qout.questions[0].question_type, QuestionType::ChooseOne);
assert_eq!(qout.questions[1].question_type, QuestionType::FreeWrite);
assert!(qout.questions[1].options.is_none());
}
#[test]
fn test_ts_produced_qout_parses_in_rust() {
let ts_json = r#"{
"seq": 1,
"questions": [
{
"id": "q1",
"type": "choose-one",
"prompt": "What kind of workflow?",
"options": ["sequential", "parallel", "mixed"]
},
{
"id": "q2",
"type": "choose-many",
"prompt": "Which harnesses?",
"options": ["claude-code", "echo-test", "custom"]
},
{
"id": "q3",
"type": "free-write",
"prompt": "Describe your workflow"
},
{
"id": "q4",
"type": "yes-no",
"prompt": "Include error retry?"
}
]
}"#;
let qout: QOutFile = serde_json::from_str(ts_json).unwrap();
assert_eq!(qout.seq, 1);
assert_eq!(qout.questions.len(), 4);
assert_eq!(qout.questions[0].question_type, QuestionType::ChooseOne);
assert_eq!(qout.questions[1].question_type, QuestionType::ChooseMany);
assert_eq!(qout.questions[2].question_type, QuestionType::FreeWrite);
assert!(qout.questions[2].options.is_none());
assert_eq!(qout.questions[3].question_type, QuestionType::YesNo);
}
#[test]
fn test_ts_produced_answers_parse_in_rust() {
let ts_json = r#"{
"seq": 1,
"answers": [
{
"id": "q1",
"type": "choose-one",
"prompt": "What kind?",
"options": ["a", "b"],
"answer": "a"
},
{
"id": "q2",
"type": "choose-many",
"prompt": "Select",
"options": ["x", "y", "z"],
"answer": ["x", "z"]
},
{
"id": "q3",
"type": "free-write",
"prompt": "Describe",
"answer": "some text"
},
{
"id": "q4",
"type": "yes-no",
"prompt": "OK?",
"answer": true
}
],
"questions": [
{"id": "q1", "type": "choose-one", "prompt": "What kind?", "options": ["a", "b"]},
{"id": "q2", "type": "choose-many", "prompt": "Select", "options": ["x", "y", "z"]},
{"id": "q3", "type": "free-write", "prompt": "Describe"},
{"id": "q4", "type": "yes-no", "prompt": "OK?"}
]
}"#;
let answers: QAnswersFile = serde_json::from_str(ts_json).unwrap();
assert_eq!(answers.seq, 1);
assert_eq!(answers.answers.len(), 4);
assert_eq!(answers.questions.len(), 4);
match &answers.answers[0].answer {
AnswerValue::Text(s) => assert_eq!(s, "a"),
_ => panic!("Expected Text"),
}
match &answers.answers[1].answer {
AnswerValue::MultiSelect(v) => assert_eq!(v, &vec!["x", "z"]),
_ => panic!("Expected MultiSelect"),
}
match &answers.answers[3].answer {
AnswerValue::Boolean(b) => assert!(*b),
_ => panic!("Expected Boolean"),
}
}
#[test]
fn test_rust_produced_json_matches_ts_format() {
let qout = QOutFile {
seq: 1,
questions: vec![Question {
id: "q1".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick".to_string(),
options: Some(vec!["a".to_string(), "b".to_string()]),
preview: None,
}],
};
let json = serde_json::to_string(&qout).unwrap();
assert!(json.contains(r#""type":"choose-one""#));
assert!(!json.contains("question_type"));
assert!(!json.contains("preview"));
}
#[test]
fn test_empty_questions_array() {
let qout = QOutFile {
seq: 0,
questions: vec![],
};
let json = serde_json::to_string(&qout).unwrap();
let parsed: QOutFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.seq, 0);
assert_eq!(parsed.questions.len(), 0);
}
#[test]
fn test_special_characters_in_prompt() {
let qout = QOutFile {
seq: 99,
questions: vec![Question {
id: "q-unicode".to_string(),
question_type: QuestionType::FreeWrite,
prompt: "Emoji: 🦀 and \"quotes\" and <tags> and \nnewline".to_string(),
options: None,
preview: None,
}],
};
let json = serde_json::to_string(&qout).unwrap();
let parsed: QOutFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.questions[0].prompt, qout.questions[0].prompt);
}
#[test]
fn test_very_long_text_survives_roundtrip() {
let long = "x".repeat(100_000);
let qout = QOutFile {
seq: 1,
questions: vec![Question {
id: "q1".to_string(),
question_type: QuestionType::FreeWrite,
prompt: long.clone(),
options: None,
preview: None,
}],
};
let json = serde_json::to_string(&qout).unwrap();
let parsed: QOutFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.questions[0].prompt.len(), 100_000);
}
#[test]
fn test_parse_seq_edge_cases() {
assert_eq!(parse_seq_from_filename("q-out-1.json"), None);
assert_eq!(parse_seq_from_filename("q-out-1234.json"), None);
assert_eq!(parse_seq_from_filename("q-out-abc.json"), None);
assert_eq!(parse_seq_from_filename("q-out-001"), None);
assert_eq!(parse_seq_from_filename(""), None);
assert_eq!(parse_seq_from_filename("q-out-999.json"), Some(999));
assert_eq!(parse_seq_from_filename("q-answers-000.json"), Some(0));
assert_eq!(parse_seq_from_filename("q-out"), None);
}
#[test]
fn test_question_to_answer_text() {
let q = Question {
id: "q1".to_string(),
question_type: QuestionType::FreeWrite,
prompt: "Describe".to_string(),
options: None,
preview: None,
};
let a = question_to_answer(&q, AnswerValue::Text("hello world".to_string()));
assert_eq!(a.id, "q1");
assert_eq!(a.question_type, QuestionType::FreeWrite);
assert_eq!(a.prompt, "Describe");
assert!(a.options.is_none());
match a.answer {
AnswerValue::Text(s) => assert_eq!(s, "hello world"),
_ => panic!("Expected Text"),
}
}
#[test]
fn test_question_to_answer_boolean_true() {
let q = Question {
id: "q-yn".to_string(),
question_type: QuestionType::YesNo,
prompt: "OK?".to_string(),
options: None,
preview: None,
};
let a = question_to_answer(&q, AnswerValue::Boolean(true));
match a.answer {
AnswerValue::Boolean(b) => assert!(b),
_ => panic!("Expected Boolean"),
}
}
#[test]
fn test_question_to_answer_boolean_false() {
let q = Question {
id: "q-yn".to_string(),
question_type: QuestionType::YesNo,
prompt: "OK?".to_string(),
options: None,
preview: None,
};
let a = question_to_answer(&q, AnswerValue::Boolean(false));
match a.answer {
AnswerValue::Boolean(b) => assert!(!b),
_ => panic!("Expected Boolean"),
}
}
#[test]
fn test_question_to_answer_multiselect() {
let q = Question {
id: "q-many".to_string(),
question_type: QuestionType::ChooseMany,
prompt: "Pick".to_string(),
options: Some(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
preview: None,
};
let a = question_to_answer(
&q,
AnswerValue::MultiSelect(vec!["a".to_string(), "c".to_string()]),
);
assert_eq!(a.options.as_ref().unwrap().len(), 3);
match a.answer {
AnswerValue::MultiSelect(v) => assert_eq!(v, vec!["a", "c"]),
_ => panic!("Expected MultiSelect"),
}
}
#[test]
fn test_question_to_answer_choose_one_preserves_options() {
let q = Question {
id: "q-one".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick".to_string(),
options: Some(vec!["x".to_string(), "y".to_string()]),
preview: None,
};
let a = question_to_answer(&q, AnswerValue::Text("y".to_string()));
assert_eq!(a.options.unwrap(), vec!["x", "y"]);
}
#[test]
fn test_invalid_json_fails_gracefully() {
let bad = r#"{"seq": 1, "questions": [NOT VALID JSON]}"#;
let result: Result<QOutFile, _> = serde_json::from_str(bad);
assert!(result.is_err());
}
#[test]
fn test_missing_required_field_fails() {
let bad = r#"{"questions": []}"#;
let result: Result<QOutFile, _> = serde_json::from_str(bad);
assert!(result.is_err());
}
#[test]
fn test_all_question_types_serialize_correctly() {
let types_and_names = [
(QuestionType::ChooseOne, "choose-one"),
(QuestionType::ChooseMany, "choose-many"),
(QuestionType::FreeWrite, "free-write"),
(QuestionType::YesNo, "yes-no"),
];
for (qt, expected_name) in types_and_names {
let q = Question {
id: "q1".to_string(),
question_type: qt,
prompt: "test".to_string(),
options: None,
preview: None,
};
let json = serde_json::to_string(&q).unwrap();
assert!(
json.contains(&format!(r#""type":"{}""#, expected_name)),
"Expected type '{}' in: {}",
expected_name,
json
);
}
}
#[test]
fn test_preview_type_serialization() {
let pb_md = PreviewBlock {
preview_type: PreviewType::Markdown,
content: "md".to_string(),
};
let pb_ascii = PreviewBlock {
preview_type: PreviewType::Ascii,
content: "ascii".to_string(),
};
let json_md = serde_json::to_string(&pb_md).unwrap();
let json_ascii = serde_json::to_string(&pb_ascii).unwrap();
assert!(json_md.contains(r#""type":"markdown""#));
assert!(json_ascii.contains(r#""type":"ascii""#));
let parsed_md: PreviewBlock = serde_json::from_str(&json_md).unwrap();
assert_eq!(parsed_md.preview_type, PreviewType::Markdown);
}
}