1use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, thiserror::Error)]
14pub enum FormsError {
15 #[error("Forms API error ({status}): {message}")]
16 Api { status: u16, message: String },
17
18 #[error(transparent)]
19 Http(#[from] reqwest::Error),
20
21 #[error(transparent)]
22 Json(#[from] serde_json::Error),
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct Form {
33 pub form_id: String,
35
36 pub info: FormInfo,
38
39 #[serde(default)]
41 pub items: Vec<Item>,
42
43 #[serde(default)]
45 pub responder_uri: String,
46
47 #[serde(default)]
49 pub revision_id: String,
50
51 pub linked_sheet_id: Option<String>,
53}
54
55impl Form {
56 pub fn title(&self) -> &str {
58 let t = self.info.title.as_str();
59 if t.is_empty() { "(Untitled)" } else { t }
60 }
61
62 pub fn question_count(&self) -> usize {
64 self.items.iter().filter(|i| i.is_question()).count()
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74#[serde(rename_all = "camelCase")]
75pub struct FormInfo {
76 #[serde(default)]
78 pub title: String,
79
80 #[serde(default)]
82 pub description: String,
83
84 #[serde(default)]
86 pub document_title: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Item {
97 #[serde(default)]
99 pub item_id: String,
100
101 #[serde(default)]
103 pub title: String,
104
105 #[serde(default)]
107 pub description: String,
108
109 pub question_item: Option<QuestionItem>,
111
112 pub page_break_item: Option<serde_json::Value>,
114}
115
116impl Item {
117 pub fn is_question(&self) -> bool {
120 self.question_item.is_some()
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct QuestionItem {
132 pub question: Question,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct Question {
143 #[serde(default)]
145 pub question_id: String,
146
147 #[serde(default)]
149 pub required: bool,
150
151 pub choice_question: Option<ChoiceQuestion>,
153
154 pub text_question: Option<TextQuestion>,
156
157 pub scale_question: Option<ScaleQuestion>,
159}
160
161impl Question {
162 pub fn question_type(&self) -> &str {
169 if self.choice_question.is_some() {
170 "choice"
171 } else if self.text_question.is_some() {
172 "text"
173 } else if self.scale_question.is_some() {
174 "scale"
175 } else {
176 "unknown"
177 }
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct ChoiceQuestion {
189 #[serde(rename = "type")]
191 pub type_: String,
192
193 #[serde(default)]
195 pub options: Vec<ChoiceOption>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ChoiceOption {
206 pub value: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct TextQuestion {
218 #[serde(default)]
220 pub paragraph: bool,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct ScaleQuestion {
231 pub low: i32,
233
234 pub high: i32,
236
237 pub low_label: Option<String>,
239
240 pub high_label: Option<String>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct FormResponse {
252 pub response_id: String,
254
255 pub create_time: String,
257
258 pub last_submitted_time: String,
260
261 pub respondent_email: Option<String>,
263
264 #[serde(default)]
266 pub answers: HashMap<String, Answer>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct Answer {
277 pub question_id: String,
279
280 pub text_answers: Option<TextAnswers>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct TextAnswers {
292 #[serde(default)]
293 pub answers: Vec<TextAnswer>,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct TextAnswer {
300 pub value: String,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ResponseList {
311 #[serde(default)]
312 pub responses: Vec<FormResponse>,
313
314 pub next_page_token: Option<String>,
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
330 fn test_form_deserialize() {
331 let json = r#"{
332 "formId": "1FAIpQLSe_abc123",
333 "info": {
334 "title": "Customer Survey",
335 "description": "Please fill in your feedback.",
336 "documentTitle": "Customer Survey (Responses)"
337 },
338 "items": [
339 {
340 "itemId": "item01",
341 "title": "What is your name?",
342 "description": "",
343 "questionItem": {
344 "question": {
345 "questionId": "q01",
346 "required": true,
347 "textQuestion": {
348 "paragraph": false
349 }
350 }
351 }
352 }
353 ],
354 "responderUri": "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform",
355 "revisionId": "rev001"
356 }"#;
357
358 let form: Form = serde_json::from_str(json).expect("deserialize form");
359 assert_eq!(form.form_id, "1FAIpQLSe_abc123");
360 assert_eq!(form.info.title, "Customer Survey");
361 assert_eq!(form.info.description, "Please fill in your feedback.");
362 assert_eq!(form.info.document_title, "Customer Survey (Responses)");
363 assert_eq!(form.items.len(), 1);
364 assert_eq!(form.items[0].item_id, "item01");
365 assert_eq!(form.items[0].title, "What is your name?");
366 assert!(form.items[0].question_item.is_some());
367 assert_eq!(form.responder_uri, "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform");
368 assert_eq!(form.revision_id, "rev001");
369 assert!(form.linked_sheet_id.is_none());
370 }
371
372 #[test]
377 fn test_form_title() {
378 let form = Form {
379 form_id: "f1".to_string(),
380 info: FormInfo {
381 title: "My Survey".to_string(),
382 description: String::new(),
383 document_title: String::new(),
384 },
385 items: vec![],
386 responder_uri: String::new(),
387 revision_id: String::new(),
388 linked_sheet_id: None,
389 };
390 assert_eq!(form.title(), "My Survey");
391 }
392
393 #[test]
398 fn test_form_title_missing() {
399 let form = Form {
400 form_id: "f2".to_string(),
401 info: FormInfo::default(),
402 items: vec![],
403 responder_uri: String::new(),
404 revision_id: String::new(),
405 linked_sheet_id: None,
406 };
407 assert_eq!(form.title(), "(Untitled)");
408 }
409
410 #[test]
415 fn test_form_question_count() {
416 let question_item = Item {
417 item_id: "i1".to_string(),
418 title: "Q1".to_string(),
419 description: String::new(),
420 question_item: Some(QuestionItem {
421 question: Question {
422 question_id: "q1".to_string(),
423 required: false,
424 choice_question: None,
425 text_question: Some(TextQuestion { paragraph: false }),
426 scale_question: None,
427 },
428 }),
429 page_break_item: None,
430 };
431
432 let page_break = Item {
433 item_id: "i2".to_string(),
434 title: "Section 2".to_string(),
435 description: String::new(),
436 question_item: None,
437 page_break_item: Some(serde_json::json!({})),
438 };
439
440 let form = Form {
441 form_id: "f3".to_string(),
442 info: FormInfo::default(),
443 items: vec![question_item, page_break],
444 responder_uri: String::new(),
445 revision_id: String::new(),
446 linked_sheet_id: None,
447 };
448
449 assert_eq!(form.question_count(), 1);
450 }
451
452 #[test]
457 fn test_item_is_question() {
458 let question_item = Item {
459 item_id: "i1".to_string(),
460 title: "Q?".to_string(),
461 description: String::new(),
462 question_item: Some(QuestionItem {
463 question: Question {
464 question_id: "q1".to_string(),
465 required: false,
466 choice_question: None,
467 text_question: Some(TextQuestion { paragraph: false }),
468 scale_question: None,
469 },
470 }),
471 page_break_item: None,
472 };
473 assert!(question_item.is_question());
474
475 let page_break = Item {
476 item_id: "i2".to_string(),
477 title: "Break".to_string(),
478 description: String::new(),
479 question_item: None,
480 page_break_item: Some(serde_json::json!({})),
481 };
482 assert!(!page_break.is_question());
483 }
484
485 #[test]
490 fn test_question_type_choice() {
491 let q = Question {
492 question_id: "q1".to_string(),
493 required: false,
494 choice_question: Some(ChoiceQuestion {
495 type_: "RADIO".to_string(),
496 options: vec![],
497 }),
498 text_question: None,
499 scale_question: None,
500 };
501 assert_eq!(q.question_type(), "choice");
502 }
503
504 #[test]
509 fn test_question_type_text() {
510 let q = Question {
511 question_id: "q2".to_string(),
512 required: true,
513 choice_question: None,
514 text_question: Some(TextQuestion { paragraph: true }),
515 scale_question: None,
516 };
517 assert_eq!(q.question_type(), "text");
518 }
519
520 #[test]
525 fn test_question_type_scale() {
526 let q = Question {
527 question_id: "q3".to_string(),
528 required: false,
529 choice_question: None,
530 text_question: None,
531 scale_question: Some(ScaleQuestion {
532 low: 1,
533 high: 5,
534 low_label: Some("Poor".to_string()),
535 high_label: Some("Excellent".to_string()),
536 }),
537 };
538 assert_eq!(q.question_type(), "scale");
539 }
540
541 #[test]
546 fn test_form_response_deserialize() {
547 let json = r#"{
548 "responseId": "resp_abc",
549 "createTime": "2024-03-01T10:00:00Z",
550 "lastSubmittedTime": "2024-03-01T10:05:00Z",
551 "respondentEmail": "alice@example.com",
552 "answers": {
553 "q01": {
554 "questionId": "q01",
555 "textAnswers": {
556 "answers": [
557 {"value": "Alice"}
558 ]
559 }
560 }
561 }
562 }"#;
563
564 let resp: FormResponse = serde_json::from_str(json).expect("deserialize form response");
565 assert_eq!(resp.response_id, "resp_abc");
566 assert_eq!(resp.create_time, "2024-03-01T10:00:00Z");
567 assert_eq!(resp.last_submitted_time, "2024-03-01T10:05:00Z");
568 assert_eq!(resp.respondent_email.as_deref(), Some("alice@example.com"));
569 assert_eq!(resp.answers.len(), 1);
570
571 let answer = resp.answers.get("q01").expect("answer for q01");
572 assert_eq!(answer.question_id, "q01");
573 let text_answers = answer.text_answers.as_ref().expect("text_answers present");
574 assert_eq!(text_answers.answers.len(), 1);
575 assert_eq!(text_answers.answers[0].value, "Alice");
576 }
577
578 #[test]
583 fn test_response_list_deserialize() {
584 let json = r#"{
585 "responses": [
586 {
587 "responseId": "r1",
588 "createTime": "2024-03-01T09:00:00Z",
589 "lastSubmittedTime": "2024-03-01T09:01:00Z",
590 "answers": {}
591 },
592 {
593 "responseId": "r2",
594 "createTime": "2024-03-01T10:00:00Z",
595 "lastSubmittedTime": "2024-03-01T10:02:00Z",
596 "answers": {}
597 }
598 ],
599 "nextPageToken": "token_xyz"
600 }"#;
601
602 let list: ResponseList = serde_json::from_str(json).expect("deserialize response list");
603 assert_eq!(list.responses.len(), 2);
604 assert_eq!(list.responses[0].response_id, "r1");
605 assert_eq!(list.responses[1].response_id, "r2");
606 assert_eq!(list.next_page_token.as_deref(), Some("token_xyz"));
607 }
608
609 #[test]
614 fn test_choice_question_options() {
615 let json = r#"{
616 "type": "CHECKBOX",
617 "options": [
618 {"value": "Option A"},
619 {"value": "Option B"},
620 {"value": "Option C"}
621 ]
622 }"#;
623
624 let cq: ChoiceQuestion = serde_json::from_str(json).expect("deserialize choice question");
625 assert_eq!(cq.type_, "CHECKBOX");
626 assert_eq!(cq.options.len(), 3);
627 assert_eq!(cq.options[0].value, "Option A");
628 assert_eq!(cq.options[1].value, "Option B");
629 assert_eq!(cq.options[2].value, "Option C");
630 }
631
632 #[test]
637 fn test_forms_error_api_display() {
638 let err = FormsError::Api { status: 403, message: "Forbidden".to_string() };
639 let msg = err.to_string();
640 assert!(msg.contains("403"), "got: {msg}");
641 assert!(msg.contains("Forbidden"), "got: {msg}");
642 }
643
644 #[test]
645 fn test_forms_error_json_display() {
646 let inner: serde_json::Error = serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
647 let err = FormsError::Json(inner);
648 let msg = err.to_string();
649 assert!(!msg.is_empty(), "error message should be non-empty");
650 }
651}