1use anyhow::Result;
19use serde::Serialize;
20
21use crate::cli::drive::format::{write_items_jsonl, JsonlSerialize};
22use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
23use crate::drive::docs::structure::{flatten, flatten_content, DocElement};
24use crate::drive::docs::types::ResolvedSegment;
25
26#[derive(Debug, Clone)]
28pub struct ReadOptions {
29 pub document_id: String,
31 pub tab: Option<String>,
37 pub suggestions: SuggestionsViewMode,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct SegmentContent {
44 pub segment_id: String,
46 pub elements: Vec<DocElement>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct TabContent {
53 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
56 pub named_ranges: std::collections::BTreeMap<String, super::types::NamedRanges>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub tab_id: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub title: Option<String>,
63 pub nesting_level: i64,
65 pub elements: Vec<DocElement>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub headers: Vec<SegmentContent>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub footers: Vec<SegmentContent>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub footnotes: Vec<SegmentContent>,
76}
77
78impl TabContent {
79 #[must_use]
86 pub fn segments_by_kind(&self) -> [(SegmentKind, &Vec<SegmentContent>); 3] {
87 [
88 (SegmentKind::Header, &self.headers),
89 (SegmentKind::Footer, &self.footers),
90 (SegmentKind::Footnote, &self.footnotes),
91 ]
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct ReadOutcome {
98 pub document_id: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub title: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
111 pub revision_id: Option<String>,
112 pub tabs: Vec<TabContent>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
118#[serde(rename_all = "lowercase")]
119pub enum SegmentKind {
120 Header,
122 Footer,
124 Footnote,
126}
127
128impl SegmentKind {
129 #[must_use]
132 pub const fn as_str(self) -> &'static str {
133 match self {
134 Self::Header => "header",
135 Self::Footer => "footer",
136 Self::Footnote => "footnote",
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
143pub struct SegmentRef<'a> {
144 pub kind: SegmentKind,
146 pub segment_id: &'a str,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct FlatElement<'a> {
163 pub document_id: &'a str,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub revision_id: Option<&'a str>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub tab_id: Option<&'a str>,
171 #[serde(skip_serializing_if = "Option::is_none")]
174 pub segment: Option<SegmentRef<'a>>,
175 #[serde(flatten)]
177 pub element: &'a DocElement,
178}
179
180impl JsonlSerialize for ReadOutcome {
181 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
182 write_items_jsonl(self.flat_elements().iter(), out)
183 }
184}
185
186impl ReadOutcome {
187 #[must_use]
192 pub fn flat_elements(&self) -> Vec<FlatElement<'_>> {
193 let mut out = Vec::new();
194 for tab in &self.tabs {
195 for element in &tab.elements {
196 out.push(FlatElement {
197 document_id: &self.document_id,
198 revision_id: self.revision_id.as_deref(),
199 tab_id: tab.tab_id.as_deref(),
200 segment: None,
201 element,
202 });
203 }
204 for (kind, segments) in tab.segments_by_kind() {
205 for segment in segments {
206 for element in &segment.elements {
207 out.push(FlatElement {
208 document_id: &self.document_id,
209 revision_id: self.revision_id.as_deref(),
210 tab_id: tab.tab_id.as_deref(),
211 segment: Some(SegmentRef {
212 kind,
213 segment_id: &segment.segment_id,
214 }),
215 element,
216 });
217 }
218 }
219 }
220 }
221 out
222 }
223}
224
225fn segments_to_content(segments: &[ResolvedSegment<'_>]) -> Vec<SegmentContent> {
228 segments
229 .iter()
230 .map(|segment| SegmentContent {
231 segment_id: segment.segment_id.to_string(),
232 elements: flatten_content(segment.content),
233 })
234 .collect()
235}
236
237pub async fn read(api: &DocsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
239 let document = api
240 .get_document(&opts.document_id, opts.suggestions)
241 .await?;
242
243 let resolved = document.resolved_tabs();
244 let mut tabs: Vec<TabContent> = resolved
245 .iter()
246 .map(|tab| TabContent {
247 named_ranges: tab
248 .named_ranges
249 .iter()
250 .map(|(name, ranges)| (name.clone(), ranges.clone()))
251 .collect(),
252 tab_id: tab.tab_id.map(ToString::to_string),
253 title: tab.title.map(ToString::to_string),
254 nesting_level: tab.nesting_level,
255 elements: tab.body.map(flatten).unwrap_or_default(),
256 headers: segments_to_content(&tab.headers()),
257 footers: segments_to_content(&tab.footers()),
258 footnotes: segments_to_content(&tab.footnotes()),
259 })
260 .collect();
261
262 if let Some(wanted) = &opts.tab {
263 let known: Vec<&str> = tabs.iter().filter_map(|t| t.tab_id.as_deref()).collect();
267 anyhow::ensure!(
268 known.contains(&wanted.as_str()),
269 "document '{}' has no tab '{wanted}'; it has {}",
270 opts.document_id,
271 if known.is_empty() {
272 "no tabs (it predates tabs, or was returned in the legacy single-body form)"
273 .to_string()
274 } else {
275 format!("tabs: {}", known.join(", "))
276 }
277 );
278 tabs.retain(|t| t.tab_id.as_deref() == Some(wanted.as_str()));
279 }
280
281 Ok(ReadOutcome {
282 document_id: opts.document_id.clone(),
283 title: document.title.clone(),
284 revision_id: document.revision_id.clone(),
285 tabs,
286 })
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used, clippy::expect_used)]
291mod tests {
292 use super::*;
293 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
294 use crate::drive::client::DriveClient;
295 use crate::drive::docs::client::{DocsClient, DOCS_API_URL};
296 use crate::drive::docs::structure::ElementKind;
297 use crate::test_support::env::MapEnv;
298 use crate::utils::secret::Secret;
299 use wiremock::matchers::{method, path, query_param};
300 use wiremock::{Mock, MockServer, ResponseTemplate};
301
302 fn test_credentials() -> DriveCredentials {
303 DriveCredentials {
304 client_id: "client-1".to_string(),
305 client_secret: Secret::new("secret-1"),
306 refresh_token: Secret::new("refresh-1"),
307 scope: DriveGrantedScopes::READONLY,
308 }
309 }
310
311 async fn docs_client(server: &MockServer) -> DocsClient {
319 Mock::given(method("POST"))
320 .and(path("/token"))
321 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
322 "access_token": "test-token",
323 "expires_in": 3600,
324 })))
325 .mount(server)
326 .await;
327
328 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
329 crate::drive::client::test_support::replace_session(
330 &mut drive,
331 &test_credentials(),
332 &format!("{}/token", server.uri()),
333 );
334 let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
335 DocsClient::from_drive_client_with(&env, &drive).unwrap()
336 }
337
338 fn opts(tab: Option<&str>) -> ReadOptions {
339 ReadOptions {
340 document_id: "d1".to_string(),
341 tab: tab.map(str::to_string),
342 suggestions: SuggestionsViewMode::default(),
343 }
344 }
345
346 fn paragraph(start: i64, end: i64, text: &str) -> serde_json::Value {
347 serde_json::json!({
348 "startIndex": start, "endIndex": end,
349 "paragraph": {
350 "elements": [{"textRun": {"content": text}}],
351 "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
352 },
353 })
354 }
355
356 fn mount_document(body: serde_json::Value) -> Mock {
357 Mock::given(method("GET"))
358 .and(path("/v1/documents/d1"))
359 .respond_with(ResponseTemplate::new(200).set_body_json(body))
360 }
361
362 #[tokio::test]
363 async fn read_calls_documents_get_exactly_once() {
364 let server = MockServer::start().await;
365 let client = docs_client(&server).await;
366 mount_document(serde_json::json!({
367 "documentId": "d1", "title": "Doc", "revisionId": "rev-1",
368 "body": {"content": [paragraph(1, 10, "hello\n")]},
369 }))
370 .expect(1)
371 .mount(&server)
372 .await;
373
374 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
375 assert_eq!(outcome.tabs.len(), 1);
376 assert_eq!(outcome.tabs[0].elements[0].text, "hello");
377 }
378
379 #[tokio::test]
382 async fn read_carries_the_revision_id_through() {
383 let server = MockServer::start().await;
384 let client = docs_client(&server).await;
385 mount_document(serde_json::json!({
386 "documentId": "d1", "revisionId": "rev-abc",
387 "body": {"content": []},
388 }))
389 .mount(&server)
390 .await;
391
392 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
393 assert_eq!(outcome.revision_id.as_deref(), Some("rev-abc"));
394 }
395
396 #[tokio::test]
398 async fn read_without_edit_access_has_no_revision_id() {
399 let server = MockServer::start().await;
400 let client = docs_client(&server).await;
401 mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
402 .mount(&server)
403 .await;
404
405 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
406 assert_eq!(outcome.revision_id, None);
407 }
408
409 #[tokio::test]
410 async fn read_of_a_tabbed_document_returns_every_tab_in_order() {
411 let server = MockServer::start().await;
412 let client = docs_client(&server).await;
413 mount_document(serde_json::json!({
414 "documentId": "d1", "revisionId": "rev-1",
415 "tabs": [
416 {
417 "tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
418 "documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
419 },
420 {
421 "tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
422 "documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
423 },
424 ],
425 }))
426 .mount(&server)
427 .await;
428
429 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
430 let ids: Vec<_> = outcome
431 .tabs
432 .iter()
433 .map(|t| t.tab_id.as_deref().unwrap())
434 .collect();
435 assert_eq!(ids, vec!["t.0", "t.1"]);
436 assert_eq!(outcome.tabs[1].elements[0].text, "b");
437 }
438
439 #[tokio::test]
440 async fn read_of_a_legacy_body_document_returns_one_anonymous_tab() {
441 let server = MockServer::start().await;
442 let client = docs_client(&server).await;
443 mount_document(serde_json::json!({
444 "documentId": "d1",
445 "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
446 }))
447 .mount(&server)
448 .await;
449
450 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
451 assert_eq!(outcome.tabs.len(), 1);
452 assert_eq!(outcome.tabs[0].tab_id, None);
453 assert_eq!(outcome.tabs[0].elements[0].kind, ElementKind::SectionBreak);
454 }
455
456 #[tokio::test]
457 async fn read_filters_to_a_single_tab() {
458 let server = MockServer::start().await;
459 let client = docs_client(&server).await;
460 mount_document(serde_json::json!({
461 "documentId": "d1",
462 "tabs": [
463 {
464 "tabProperties": {"tabId": "t.0", "title": "One"},
465 "documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
466 },
467 {
468 "tabProperties": {"tabId": "t.1", "title": "Two"},
469 "documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
470 },
471 ],
472 }))
473 .mount(&server)
474 .await;
475
476 let outcome = read(&DocsApi::new(&client), &opts(Some("t.1")))
477 .await
478 .unwrap();
479 assert_eq!(outcome.tabs.len(), 1);
480 assert_eq!(outcome.tabs[0].elements[0].text, "b");
481 }
482
483 #[tokio::test]
485 async fn read_with_an_unknown_tab_id_errors_and_names_the_real_ids() {
486 let server = MockServer::start().await;
487 let client = docs_client(&server).await;
488 mount_document(serde_json::json!({
489 "documentId": "d1",
490 "tabs": [
491 {"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
492 {"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
493 ],
494 }))
495 .mount(&server)
496 .await;
497
498 let err = read(&DocsApi::new(&client), &opts(Some("nope")))
499 .await
500 .unwrap_err()
501 .to_string();
502 assert!(err.contains("no tab 'nope'"), "{err}");
503 assert!(err.contains("t.0"), "{err}");
504 assert!(err.contains("t.1"), "{err}");
505 }
506
507 #[tokio::test]
510 async fn read_with_a_tab_filter_on_a_legacy_document_explains_itself() {
511 let server = MockServer::start().await;
512 let client = docs_client(&server).await;
513 mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
514 .mount(&server)
515 .await;
516
517 let err = read(&DocsApi::new(&client), &opts(Some("t.0")))
518 .await
519 .unwrap_err()
520 .to_string();
521 assert!(err.contains("no tabs"), "{err}");
522 }
523
524 #[tokio::test]
525 async fn read_surfaces_an_api_error_with_its_status_and_message() {
526 let server = MockServer::start().await;
527 let client = docs_client(&server).await;
528 Mock::given(method("GET"))
529 .and(path("/v1/documents/d1"))
530 .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
531 "error": {"code": 404, "message": "Requested entity was not found.",
532 "status": "NOT_FOUND"},
533 })))
534 .mount(&server)
535 .await;
536
537 let err = read(&DocsApi::new(&client), &opts(None))
538 .await
539 .unwrap_err()
540 .to_string();
541 assert!(err.contains("Docs API request failed"), "{err}");
542 assert!(err.contains("404"), "{err}");
543 assert!(err.contains("Requested entity was not found."), "{err}");
544 }
545
546 #[tokio::test]
547 async fn read_honours_the_suggestions_view_mode() {
548 let server = MockServer::start().await;
549 let client = docs_client(&server).await;
550 Mock::given(method("GET"))
551 .and(path("/v1/documents/d1"))
552 .and(query_param(
553 "suggestionsViewMode",
554 "PREVIEW_SUGGESTIONS_ACCEPTED",
555 ))
556 .and(query_param("includeTabsContent", "true"))
557 .respond_with(
558 ResponseTemplate::new(200).set_body_json(
559 serde_json::json!({"documentId": "d1", "body": {"content": []}}),
560 ),
561 )
562 .expect(1)
563 .mount(&server)
564 .await;
565
566 let options = ReadOptions {
567 suggestions: SuggestionsViewMode::PreviewAccepted,
568 ..opts(None)
569 };
570 read(&DocsApi::new(&client), &options).await.unwrap();
571 }
572
573 #[tokio::test]
574 async fn read_outcome_serialises_tabs_as_an_ordered_list() {
575 let server = MockServer::start().await;
576 let client = docs_client(&server).await;
577 mount_document(serde_json::json!({
578 "documentId": "d1", "title": "T", "revisionId": "rev-1",
579 "tabs": [
580 {"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
581 {"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
582 ],
583 }))
584 .mount(&server)
585 .await;
586
587 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
588 let json = serde_json::to_value(&outcome).unwrap();
589 assert!(json["tabs"].is_array());
592 assert_eq!(json["tabs"][0]["tab_id"], "t.0");
593 assert_eq!(json["revision_id"], "rev-1");
594 }
595
596 #[tokio::test]
597 async fn read_outcome_omits_absent_optional_fields() {
598 let server = MockServer::start().await;
599 let client = docs_client(&server).await;
600 mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
601 .mount(&server)
602 .await;
603
604 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
605 let json = serde_json::to_value(&outcome).unwrap();
606 assert!(json.get("title").is_none());
607 assert!(json.get("revision_id").is_none());
608 assert!(json["tabs"][0].get("tab_id").is_none());
609 }
610
611 async fn two_tab_outcome(server: &MockServer) -> ReadOutcome {
614 let client = docs_client(server).await;
615 mount_document(serde_json::json!({
616 "documentId": "d1",
617 "revisionId": "rev-1",
618 "tabs": [
619 {
620 "tabProperties": {"tabId": "t.0"},
621 "documentTab": {"body": {"content": [
622 paragraph(1, 5, "a\n"), paragraph(5, 9, "b\n")]}},
623 },
624 {
625 "tabProperties": {"tabId": "t.1"},
626 "documentTab": {"body": {"content": [paragraph(1, 5, "c\n")]}},
627 },
628 ],
629 }))
630 .mount(server)
631 .await;
632
633 read(&DocsApi::new(&client), &opts(None)).await.unwrap()
634 }
635
636 fn render_jsonl(outcome: &ReadOutcome) -> String {
644 let mut buf = Vec::new();
645 outcome.write_jsonl(&mut buf).unwrap();
646 String::from_utf8(buf).unwrap()
647 }
648
649 #[tokio::test]
651 async fn jsonl_emits_one_line_per_element_across_every_tab() {
652 let server = MockServer::start().await;
653 let outcome = two_tab_outcome(&server).await;
654
655 let rendered = render_jsonl(&outcome);
656 let lines: Vec<&str> = rendered.lines().collect();
657 assert_eq!(lines.len(), 3);
658
659 let texts: Vec<String> = lines
660 .iter()
661 .map(|line| {
662 serde_json::from_str::<serde_json::Value>(line).unwrap()["text"].to_string()
663 })
664 .collect();
665 assert_eq!(texts, vec!["\"a\"", "\"b\"", "\"c\""]);
666 }
667
668 #[tokio::test]
671 async fn jsonl_denormalises_document_and_tab_identity_onto_every_line() {
672 let server = MockServer::start().await;
673 let outcome = two_tab_outcome(&server).await;
674 let rendered = render_jsonl(&outcome);
675
676 let lines: Vec<serde_json::Value> = rendered
677 .lines()
678 .map(|line| serde_json::from_str(line).unwrap())
679 .collect();
680
681 for line in &lines {
682 assert_eq!(line["document_id"], "d1");
683 assert_eq!(line["revision_id"], "rev-1");
684 }
685 assert_eq!(lines[0]["tab_id"], "t.0");
686 assert_eq!(lines[1]["tab_id"], "t.0");
687 assert_eq!(lines[2]["tab_id"], "t.1");
688 }
689
690 #[tokio::test]
694 async fn jsonl_flattens_element_fields_to_the_top_level() {
695 let server = MockServer::start().await;
696 let outcome = two_tab_outcome(&server).await;
697 let rendered = render_jsonl(&outcome);
698
699 let first: serde_json::Value =
700 serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
701 assert_eq!(first["text"], "a");
702 assert_eq!(first["kind"], "paragraph");
703 assert_eq!(first["start_index"], 1);
704 assert_eq!(first["end_index"], 5);
705 assert!(first.get("element").is_none());
706 }
707
708 #[tokio::test]
711 async fn jsonl_omits_revision_and_tab_when_absent() {
712 let server = MockServer::start().await;
713 let client = docs_client(&server).await;
714 mount_document(serde_json::json!({
715 "documentId": "d1",
716 "body": {"content": [paragraph(1, 5, "a\n")]},
717 }))
718 .mount(&server)
719 .await;
720
721 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
722 let rendered = render_jsonl(&outcome);
723 let line: serde_json::Value =
724 serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
725
726 assert_eq!(line["document_id"], "d1");
727 assert!(line.get("revision_id").is_none());
728 assert!(line.get("tab_id").is_none());
729 }
730
731 #[tokio::test]
734 async fn jsonl_emits_nothing_for_a_document_with_no_elements() {
735 let server = MockServer::start().await;
736 let client = docs_client(&server).await;
737 mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
738 .mount(&server)
739 .await;
740
741 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
742 assert_eq!(render_jsonl(&outcome), "");
743 }
744
745 #[tokio::test]
748 async fn read_populates_headers_footers_and_footnotes() {
749 let server = MockServer::start().await;
750 let client = docs_client(&server).await;
751 mount_document(serde_json::json!({
752 "documentId": "d1",
753 "body": {"content": [paragraph(1, 5, "body\n")]},
754 "headers": {"h1": {"content": [paragraph(0, 8, "Header\n")]}},
755 "footers": {"f1": {"content": [paragraph(0, 8, "Footer\n")]}},
756 "footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
757 }))
758 .mount(&server)
759 .await;
760
761 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
762 let tab = &outcome.tabs[0];
763 assert_eq!(tab.headers.len(), 1);
764 assert_eq!(tab.headers[0].segment_id, "h1");
765 assert_eq!(tab.headers[0].elements[0].text, "Header");
766 assert_eq!(tab.footers[0].elements[0].text, "Footer");
767 assert_eq!(tab.footnotes[0].elements[0].text, "Note");
768 }
769
770 #[tokio::test]
773 async fn a_document_with_no_segments_serialises_without_the_new_fields() {
774 let server = MockServer::start().await;
775 let client = docs_client(&server).await;
776 mount_document(serde_json::json!({
777 "documentId": "d1",
778 "body": {"content": [paragraph(1, 5, "a\n")]},
779 }))
780 .mount(&server)
781 .await;
782
783 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
784 let json = serde_json::to_value(&outcome).unwrap();
785 let tab = &json["tabs"][0];
786 assert!(tab.get("headers").is_none());
787 assert!(tab.get("footers").is_none());
788 assert!(tab.get("footnotes").is_none());
789 }
790
791 #[tokio::test]
794 async fn jsonl_carries_segment_identity_for_segment_elements() {
795 let server = MockServer::start().await;
796 let client = docs_client(&server).await;
797 mount_document(serde_json::json!({
798 "documentId": "d1",
799 "body": {"content": [paragraph(1, 5, "body\n")]},
800 "footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
801 }))
802 .mount(&server)
803 .await;
804
805 let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
806 let rendered = render_jsonl(&outcome);
807 let lines: Vec<serde_json::Value> = rendered
808 .lines()
809 .map(|line| serde_json::from_str(line).unwrap())
810 .collect();
811
812 assert_eq!(lines.len(), 2);
813 assert!(lines[0].get("segment").is_none());
814 assert_eq!(lines[1]["segment"]["kind"], "footnote");
815 assert_eq!(lines[1]["segment"]["segment_id"], "n1");
816 assert_eq!(lines[1]["text"], "Note");
817 }
818}