Skip to main content

omni_dev/drive/docs/
read.rs

1//! `drive docs read` engine — one `documents.get`, flattened into an
2//! index-ordered element list per tab.
3//!
4//! **Ungated**, exactly as `sheets read` and the rest of the Drive read
5//! surface are. [ADR-0071](../../../docs/adrs/adr-0071.md) §11 records
6//! read-path enforcement as a known, scoped gap across the whole
7//! integration, pending a batching/caching design; this command inherits
8//! that gap rather than opting out of it, and will inherit its resolution.
9//!
10//! There is deliberately **no per-tab cap** of the kind `sheets read`'s
11//! `MAX_SHEETS_PER_READ` imposes. That cap exists because a whole-workbook
12//! read fans out into *N* `values.batchGet` requests and could return a
13//! partially-fetched workbook. `documents.get` is exactly one request whose
14//! result is all-or-nothing, so a cap could only refuse a document already
15//! in hand; `MAX_DOCUMENT_BYTES` is the analogous protection and it sits at
16//! the right layer.
17
18use 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/// Per-call options.
27#[derive(Debug, Clone)]
28pub struct ReadOptions {
29    /// The document to read.
30    pub document_id: String,
31    /// Restrict output to one tab id.
32    ///
33    /// Applied **client-side after the fetch** — `documents.get` has no
34    /// per-tab endpoint, and fetching every tab is what makes the tab list
35    /// (and so a useful error for a wrong id) available at all.
36    pub tab: Option<String>,
37    /// Which suggestion view the indices are reported against.
38    pub suggestions: SuggestionsViewMode,
39}
40
41/// One header, footer or footnote segment's flattened content.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct SegmentContent {
44    /// The segment's id — the map key Docs stores it under.
45    pub segment_id: String,
46    /// The segment's structural elements, in index order.
47    pub elements: Vec<DocElement>,
48}
49
50/// One tab's flattened content.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct TabContent {
53    /// Named-range IDs and their full spans, sorted by name in JSON/YAML.
54    /// JSONL continues to stream only structural elements.
55    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
56    pub named_ranges: std::collections::BTreeMap<String, super::types::NamedRanges>,
57    /// The tab's id, absent for a legacy single-body document.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub tab_id: Option<String>,
60    /// The tab's title, likewise.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub title: Option<String>,
63    /// Depth in the tab tree.
64    pub nesting_level: i64,
65    /// The tab's structural elements, in index order.
66    pub elements: Vec<DocElement>,
67    /// The tab's page headers, when it has any, ordered by segment id.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub headers: Vec<SegmentContent>,
70    /// The tab's page footers, likewise.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub footers: Vec<SegmentContent>,
73    /// The tab's footnotes, likewise.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub footnotes: Vec<SegmentContent>,
76}
77
78impl TabContent {
79    /// Every segment kind's content, in header/footer/footnote order.
80    ///
81    /// The single source of truth both [`ReadOutcome::flat_elements`]
82    /// (`-o jsonl`) and the CLI's `-o table` renderer walk, so a future
83    /// segment kind is added in one place rather than kept in sync by hand
84    /// across both.
85    #[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/// The full result of one read.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct ReadOutcome {
98    /// The document read.
99    pub document_id: String,
100    /// Its title, when the response carried one.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub title: Option<String>,
103    /// The document's revision at the moment of the read.
104    ///
105    /// This is the `writeControl.requiredRevisionId` token a later
106    /// `documents.batchUpdate` presents so a write against a document that
107    /// moved underneath it is refused rather than silently misapplied.
108    /// Absent when the caller lacks edit access, which Google signals by
109    /// omitting it.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub revision_id: Option<String>,
112    /// The tabs read, in document order.
113    pub tabs: Vec<TabContent>,
114}
115
116/// Which kind of segment a header, footer or footnote is.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
118#[serde(rename_all = "lowercase")]
119pub enum SegmentKind {
120    /// A page header.
121    Header,
122    /// A page footer.
123    Footer,
124    /// A footnote.
125    Footnote,
126}
127
128impl SegmentKind {
129    /// `"header"`, `"footer"` or `"footnote"` — the label the CLI's
130    /// `-o table` renderer uses for a segment's block heading.
131    #[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/// Identifies a header/footer/footnote segment on a `-o jsonl` line.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
143pub struct SegmentRef<'a> {
144    /// The segment's kind.
145    pub kind: SegmentKind,
146    /// The segment's id — the map key Docs stores it under.
147    pub segment_id: &'a str,
148}
149
150/// One element, with the document and tab identity denormalised onto it.
151///
152/// The `-o jsonl` record. A document's element list *is* a record stream,
153/// unlike a sheet's rows, so each line is one element rather than one line
154/// per document. The repetition of `document_id`/`revision_id`/`tab_id` on
155/// every line is the point — it is what makes a single line self-describing
156/// to `jq`, which is the whole reason to reach for `jsonl` over `json`.
157///
158/// The element's own fields are `flatten`ed to the top level rather than
159/// nested under a key, so the natural filter is `jq -r '.text'` and every
160/// field name matches the one `-o json` uses at its own depth.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct FlatElement<'a> {
163    /// The document these elements came from.
164    pub document_id: &'a str,
165    /// The revision they were read at, when the caller has edit access.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub revision_id: Option<&'a str>,
168    /// The holding tab's id, absent for a legacy single-body document.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub tab_id: Option<&'a str>,
171    /// Which header/footer/footnote segment this element came from, absent
172    /// for a body element.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub segment: Option<SegmentRef<'a>>,
175    /// The element itself, inlined.
176    #[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    /// Every element across every tab, carrying the identity of the document,
188    /// tab and (for a header/footer/footnote element) segment that hold it.
189    ///
190    /// This is the `-o jsonl` projection; see [`FlatElement`].
191    #[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
225/// Flattens a resolved tab's header/footer/footnote segments, preserving the
226/// `segment_id` ordering [`ResolvedSegment`]s already carry.
227fn 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
237/// Reads a document and flattens each tab's body and segments.
238pub 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        // An unknown tab id is an *error* naming the real ones, never an
264        // empty result: a typo'd id and a genuinely empty tab must not look
265        // alike. Same reasoning as `sheets read`'s unknown-sheet handling.
266        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    /// Builds a Docs client pointed at wiremock, via the real derivation
312    /// path.
313    ///
314    /// Note the ordering: `replace_session` swaps the Drive client's whole
315    /// transport, so it must run **before** the derive. Deriving first would
316    /// leave the Docs client holding the original session, pointed at the
317    /// real `oauth2.googleapis.com` — a live network call from a unit test.
318    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    /// The lease token has to survive the read, or the write phase has
380    /// nothing to present.
381    #[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    /// A reader-only caller gets no `revisionId`; the read still succeeds.
397    #[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    /// A typo'd tab id and a genuinely empty tab must not look alike.
484    #[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    /// Asking for a tab of a legacy single-body document explains *why*
508    /// there are none, rather than printing an empty id list.
509    #[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        // A list, never a map: tab order is meaningful and map key order is
590        // not guaranteed through serde_json.
591        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    /// A two-tab document whose `-o jsonl` rendering the next few tests
612    /// assert against.
613    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    /// Renders `outcome` the way `-o jsonl` does, as the bytes a caller pipes
637    /// into `jq`.
638    ///
639    /// Asserting on the rendered output rather than on `flat_elements()` is
640    /// deliberate: the helper existed and was correct while `write_jsonl`
641    /// still emitted one line for the whole document, so a test of the
642    /// projection alone cannot see the bug that mattered.
643    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    /// The `-o jsonl` shape: one record per element, not one per document.
650    #[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    /// Each line carries the document and tab identity, which is what makes
669    /// it self-describing once it is separated from its siblings.
670    #[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    /// The element's own fields sit at the top level, not nested under a
691    /// key — `jq -r '.text'` is the documented filter, and a wrapper object
692    /// would silently make it yield `null`.
693    #[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    /// A read-only caller has no `revisionId`, and the field is omitted
709    /// rather than rendered as `null` — the same contract `-o json` keeps.
710    #[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    /// An empty document renders no lines at all, rather than one line
732    /// describing a document with nothing in it.
733    #[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    /// A document response with headers, footers and footnotes is no longer
746    /// a silent drop — this is the read half of issue #1799.
747    #[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    /// A document with none of these segments still round-trips exactly as
771    /// before: the new fields are omitted, not emitted as empty arrays.
772    #[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    /// `-o jsonl` carries segment identity on each of its lines, and leaves
792    /// it off body lines rather than emitting `null`.
793    #[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}