use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum FormsError {
#[error("Forms API error ({status}): {message}")]
Api { status: u16, message: String },
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Form {
pub form_id: String,
pub info: FormInfo,
#[serde(default)]
pub items: Vec<Item>,
#[serde(default)]
pub responder_uri: String,
#[serde(default)]
pub revision_id: String,
pub linked_sheet_id: Option<String>,
}
impl Form {
pub fn title(&self) -> &str {
let t = self.info.title.as_str();
if t.is_empty() { "(Untitled)" } else { t }
}
pub fn question_count(&self) -> usize {
self.items.iter().filter(|i| i.is_question()).count()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct FormInfo {
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub document_title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Item {
#[serde(default)]
pub item_id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
pub question_item: Option<QuestionItem>,
pub page_break_item: Option<serde_json::Value>,
}
impl Item {
pub fn is_question(&self) -> bool {
self.question_item.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuestionItem {
pub question: Question,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Question {
#[serde(default)]
pub question_id: String,
#[serde(default)]
pub required: bool,
pub choice_question: Option<ChoiceQuestion>,
pub text_question: Option<TextQuestion>,
pub scale_question: Option<ScaleQuestion>,
}
impl Question {
pub fn question_type(&self) -> &str {
if self.choice_question.is_some() {
"choice"
} else if self.text_question.is_some() {
"text"
} else if self.scale_question.is_some() {
"scale"
} else {
"unknown"
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChoiceQuestion {
#[serde(rename = "type")]
pub type_: String,
#[serde(default)]
pub options: Vec<ChoiceOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChoiceOption {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextQuestion {
#[serde(default)]
pub paragraph: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScaleQuestion {
pub low: i32,
pub high: i32,
pub low_label: Option<String>,
pub high_label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormResponse {
pub response_id: String,
pub create_time: String,
pub last_submitted_time: String,
pub respondent_email: Option<String>,
#[serde(default)]
pub answers: HashMap<String, Answer>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Answer {
pub question_id: String,
pub text_answers: Option<TextAnswers>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextAnswers {
#[serde(default)]
pub answers: Vec<TextAnswer>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextAnswer {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResponseList {
#[serde(default)]
pub responses: Vec<FormResponse>,
pub next_page_token: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_form_deserialize() {
let json = r#"{
"formId": "1FAIpQLSe_abc123",
"info": {
"title": "Customer Survey",
"description": "Please fill in your feedback.",
"documentTitle": "Customer Survey (Responses)"
},
"items": [
{
"itemId": "item01",
"title": "What is your name?",
"description": "",
"questionItem": {
"question": {
"questionId": "q01",
"required": true,
"textQuestion": {
"paragraph": false
}
}
}
}
],
"responderUri": "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform",
"revisionId": "rev001"
}"#;
let form: Form = serde_json::from_str(json).expect("deserialize form");
assert_eq!(form.form_id, "1FAIpQLSe_abc123");
assert_eq!(form.info.title, "Customer Survey");
assert_eq!(form.info.description, "Please fill in your feedback.");
assert_eq!(form.info.document_title, "Customer Survey (Responses)");
assert_eq!(form.items.len(), 1);
assert_eq!(form.items[0].item_id, "item01");
assert_eq!(form.items[0].title, "What is your name?");
assert!(form.items[0].question_item.is_some());
assert_eq!(form.responder_uri, "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform");
assert_eq!(form.revision_id, "rev001");
assert!(form.linked_sheet_id.is_none());
}
#[test]
fn test_form_title() {
let form = Form {
form_id: "f1".to_string(),
info: FormInfo {
title: "My Survey".to_string(),
description: String::new(),
document_title: String::new(),
},
items: vec![],
responder_uri: String::new(),
revision_id: String::new(),
linked_sheet_id: None,
};
assert_eq!(form.title(), "My Survey");
}
#[test]
fn test_form_title_missing() {
let form = Form {
form_id: "f2".to_string(),
info: FormInfo::default(),
items: vec![],
responder_uri: String::new(),
revision_id: String::new(),
linked_sheet_id: None,
};
assert_eq!(form.title(), "(Untitled)");
}
#[test]
fn test_form_question_count() {
let question_item = Item {
item_id: "i1".to_string(),
title: "Q1".to_string(),
description: String::new(),
question_item: Some(QuestionItem {
question: Question {
question_id: "q1".to_string(),
required: false,
choice_question: None,
text_question: Some(TextQuestion { paragraph: false }),
scale_question: None,
},
}),
page_break_item: None,
};
let page_break = Item {
item_id: "i2".to_string(),
title: "Section 2".to_string(),
description: String::new(),
question_item: None,
page_break_item: Some(serde_json::json!({})),
};
let form = Form {
form_id: "f3".to_string(),
info: FormInfo::default(),
items: vec![question_item, page_break],
responder_uri: String::new(),
revision_id: String::new(),
linked_sheet_id: None,
};
assert_eq!(form.question_count(), 1);
}
#[test]
fn test_item_is_question() {
let question_item = Item {
item_id: "i1".to_string(),
title: "Q?".to_string(),
description: String::new(),
question_item: Some(QuestionItem {
question: Question {
question_id: "q1".to_string(),
required: false,
choice_question: None,
text_question: Some(TextQuestion { paragraph: false }),
scale_question: None,
},
}),
page_break_item: None,
};
assert!(question_item.is_question());
let page_break = Item {
item_id: "i2".to_string(),
title: "Break".to_string(),
description: String::new(),
question_item: None,
page_break_item: Some(serde_json::json!({})),
};
assert!(!page_break.is_question());
}
#[test]
fn test_question_type_choice() {
let q = Question {
question_id: "q1".to_string(),
required: false,
choice_question: Some(ChoiceQuestion {
type_: "RADIO".to_string(),
options: vec![],
}),
text_question: None,
scale_question: None,
};
assert_eq!(q.question_type(), "choice");
}
#[test]
fn test_question_type_text() {
let q = Question {
question_id: "q2".to_string(),
required: true,
choice_question: None,
text_question: Some(TextQuestion { paragraph: true }),
scale_question: None,
};
assert_eq!(q.question_type(), "text");
}
#[test]
fn test_question_type_scale() {
let q = Question {
question_id: "q3".to_string(),
required: false,
choice_question: None,
text_question: None,
scale_question: Some(ScaleQuestion {
low: 1,
high: 5,
low_label: Some("Poor".to_string()),
high_label: Some("Excellent".to_string()),
}),
};
assert_eq!(q.question_type(), "scale");
}
#[test]
fn test_form_response_deserialize() {
let json = r#"{
"responseId": "resp_abc",
"createTime": "2024-03-01T10:00:00Z",
"lastSubmittedTime": "2024-03-01T10:05:00Z",
"respondentEmail": "alice@example.com",
"answers": {
"q01": {
"questionId": "q01",
"textAnswers": {
"answers": [
{"value": "Alice"}
]
}
}
}
}"#;
let resp: FormResponse = serde_json::from_str(json).expect("deserialize form response");
assert_eq!(resp.response_id, "resp_abc");
assert_eq!(resp.create_time, "2024-03-01T10:00:00Z");
assert_eq!(resp.last_submitted_time, "2024-03-01T10:05:00Z");
assert_eq!(resp.respondent_email.as_deref(), Some("alice@example.com"));
assert_eq!(resp.answers.len(), 1);
let answer = resp.answers.get("q01").expect("answer for q01");
assert_eq!(answer.question_id, "q01");
let text_answers = answer.text_answers.as_ref().expect("text_answers present");
assert_eq!(text_answers.answers.len(), 1);
assert_eq!(text_answers.answers[0].value, "Alice");
}
#[test]
fn test_response_list_deserialize() {
let json = r#"{
"responses": [
{
"responseId": "r1",
"createTime": "2024-03-01T09:00:00Z",
"lastSubmittedTime": "2024-03-01T09:01:00Z",
"answers": {}
},
{
"responseId": "r2",
"createTime": "2024-03-01T10:00:00Z",
"lastSubmittedTime": "2024-03-01T10:02:00Z",
"answers": {}
}
],
"nextPageToken": "token_xyz"
}"#;
let list: ResponseList = serde_json::from_str(json).expect("deserialize response list");
assert_eq!(list.responses.len(), 2);
assert_eq!(list.responses[0].response_id, "r1");
assert_eq!(list.responses[1].response_id, "r2");
assert_eq!(list.next_page_token.as_deref(), Some("token_xyz"));
}
#[test]
fn test_choice_question_options() {
let json = r#"{
"type": "CHECKBOX",
"options": [
{"value": "Option A"},
{"value": "Option B"},
{"value": "Option C"}
]
}"#;
let cq: ChoiceQuestion = serde_json::from_str(json).expect("deserialize choice question");
assert_eq!(cq.type_, "CHECKBOX");
assert_eq!(cq.options.len(), 3);
assert_eq!(cq.options[0].value, "Option A");
assert_eq!(cq.options[1].value, "Option B");
assert_eq!(cq.options[2].value, "Option C");
}
#[test]
fn test_forms_error_api_display() {
let err = FormsError::Api { status: 403, message: "Forbidden".to_string() };
let msg = err.to_string();
assert!(msg.contains("403"), "got: {msg}");
assert!(msg.contains("Forbidden"), "got: {msg}");
}
#[test]
fn test_forms_error_json_display() {
let inner: serde_json::Error = serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
let err = FormsError::Json(inner);
let msg = err.to_string();
assert!(!msg.is_empty(), "error message should be non-empty");
}
}