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    /// The tab's id, absent for a legacy single-body document.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tab_id: Option<String>,
56    /// The tab's title, likewise.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub title: Option<String>,
59    /// Depth in the tab tree.
60    pub nesting_level: i64,
61    /// The tab's structural elements, in index order.
62    pub elements: Vec<DocElement>,
63    /// The tab's page headers, when it has any, ordered by segment id.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub headers: Vec<SegmentContent>,
66    /// The tab's page footers, likewise.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub footers: Vec<SegmentContent>,
69    /// The tab's footnotes, likewise.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub footnotes: Vec<SegmentContent>,
72}
73
74impl TabContent {
75    /// Every segment kind's content, in header/footer/footnote order.
76    ///
77    /// The single source of truth both [`ReadOutcome::flat_elements`]
78    /// (`-o jsonl`) and the CLI's `-o table` renderer walk, so a future
79    /// segment kind is added in one place rather than kept in sync by hand
80    /// across both.
81    #[must_use]
82    pub fn segments_by_kind(&self) -> [(SegmentKind, &Vec<SegmentContent>); 3] {
83        [
84            (SegmentKind::Header, &self.headers),
85            (SegmentKind::Footer, &self.footers),
86            (SegmentKind::Footnote, &self.footnotes),
87        ]
88    }
89}
90
91/// The full result of one read.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct ReadOutcome {
94    /// The document read.
95    pub document_id: String,
96    /// Its title, when the response carried one.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub title: Option<String>,
99    /// The document's revision at the moment of the read.
100    ///
101    /// This is the `writeControl.requiredRevisionId` token a later
102    /// `documents.batchUpdate` presents so a write against a document that
103    /// moved underneath it is refused rather than silently misapplied.
104    /// Absent when the caller lacks edit access, which Google signals by
105    /// omitting it.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub revision_id: Option<String>,
108    /// The tabs read, in document order.
109    pub tabs: Vec<TabContent>,
110}
111
112/// Which kind of segment a header, footer or footnote is.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
114#[serde(rename_all = "lowercase")]
115pub enum SegmentKind {
116    /// A page header.
117    Header,
118    /// A page footer.
119    Footer,
120    /// A footnote.
121    Footnote,
122}
123
124impl SegmentKind {
125    /// `"header"`, `"footer"` or `"footnote"` — the label the CLI's
126    /// `-o table` renderer uses for a segment's block heading.
127    #[must_use]
128    pub const fn as_str(self) -> &'static str {
129        match self {
130            Self::Header => "header",
131            Self::Footer => "footer",
132            Self::Footnote => "footnote",
133        }
134    }
135}
136
137/// Identifies a header/footer/footnote segment on a `-o jsonl` line.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139pub struct SegmentRef<'a> {
140    /// The segment's kind.
141    pub kind: SegmentKind,
142    /// The segment's id — the map key Docs stores it under.
143    pub segment_id: &'a str,
144}
145
146/// One element, with the document and tab identity denormalised onto it.
147///
148/// The `-o jsonl` record. A document's element list *is* a record stream,
149/// unlike a sheet's rows, so each line is one element rather than one line
150/// per document. The repetition of `document_id`/`revision_id`/`tab_id` on
151/// every line is the point — it is what makes a single line self-describing
152/// to `jq`, which is the whole reason to reach for `jsonl` over `json`.
153///
154/// The element's own fields are `flatten`ed to the top level rather than
155/// nested under a key, so the natural filter is `jq -r '.text'` and every
156/// field name matches the one `-o json` uses at its own depth.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct FlatElement<'a> {
159    /// The document these elements came from.
160    pub document_id: &'a str,
161    /// The revision they were read at, when the caller has edit access.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub revision_id: Option<&'a str>,
164    /// The holding tab's id, absent for a legacy single-body document.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub tab_id: Option<&'a str>,
167    /// Which header/footer/footnote segment this element came from, absent
168    /// for a body element.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub segment: Option<SegmentRef<'a>>,
171    /// The element itself, inlined.
172    #[serde(flatten)]
173    pub element: &'a DocElement,
174}
175
176impl JsonlSerialize for ReadOutcome {
177    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
178        write_items_jsonl(self.flat_elements().iter(), out)
179    }
180}
181
182impl ReadOutcome {
183    /// Every element across every tab, carrying the identity of the document,
184    /// tab and (for a header/footer/footnote element) segment that hold it.
185    ///
186    /// This is the `-o jsonl` projection; see [`FlatElement`].
187    #[must_use]
188    pub fn flat_elements(&self) -> Vec<FlatElement<'_>> {
189        let mut out = Vec::new();
190        for tab in &self.tabs {
191            for element in &tab.elements {
192                out.push(FlatElement {
193                    document_id: &self.document_id,
194                    revision_id: self.revision_id.as_deref(),
195                    tab_id: tab.tab_id.as_deref(),
196                    segment: None,
197                    element,
198                });
199            }
200            for (kind, segments) in tab.segments_by_kind() {
201                for segment in segments {
202                    for element in &segment.elements {
203                        out.push(FlatElement {
204                            document_id: &self.document_id,
205                            revision_id: self.revision_id.as_deref(),
206                            tab_id: tab.tab_id.as_deref(),
207                            segment: Some(SegmentRef {
208                                kind,
209                                segment_id: &segment.segment_id,
210                            }),
211                            element,
212                        });
213                    }
214                }
215            }
216        }
217        out
218    }
219}
220
221/// Flattens a resolved tab's header/footer/footnote segments, preserving the
222/// `segment_id` ordering [`ResolvedSegment`]s already carry.
223fn segments_to_content(segments: &[ResolvedSegment<'_>]) -> Vec<SegmentContent> {
224    segments
225        .iter()
226        .map(|segment| SegmentContent {
227            segment_id: segment.segment_id.to_string(),
228            elements: flatten_content(segment.content),
229        })
230        .collect()
231}
232
233/// Reads a document and flattens each tab's body and segments.
234pub async fn read(api: &DocsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
235    let document = api
236        .get_document(&opts.document_id, opts.suggestions)
237        .await?;
238
239    let resolved = document.resolved_tabs();
240    let mut tabs: Vec<TabContent> = resolved
241        .iter()
242        .map(|tab| TabContent {
243            tab_id: tab.tab_id.map(ToString::to_string),
244            title: tab.title.map(ToString::to_string),
245            nesting_level: tab.nesting_level,
246            elements: tab.body.map(flatten).unwrap_or_default(),
247            headers: segments_to_content(&tab.headers()),
248            footers: segments_to_content(&tab.footers()),
249            footnotes: segments_to_content(&tab.footnotes()),
250        })
251        .collect();
252
253    if let Some(wanted) = &opts.tab {
254        // An unknown tab id is an *error* naming the real ones, never an
255        // empty result: a typo'd id and a genuinely empty tab must not look
256        // alike. Same reasoning as `sheets read`'s unknown-sheet handling.
257        let known: Vec<&str> = tabs.iter().filter_map(|t| t.tab_id.as_deref()).collect();
258        anyhow::ensure!(
259            known.contains(&wanted.as_str()),
260            "document '{}' has no tab '{wanted}'; it has {}",
261            opts.document_id,
262            if known.is_empty() {
263                "no tabs (it predates tabs, or was returned in the legacy single-body form)"
264                    .to_string()
265            } else {
266                format!("tabs: {}", known.join(", "))
267            }
268        );
269        tabs.retain(|t| t.tab_id.as_deref() == Some(wanted.as_str()));
270    }
271
272    Ok(ReadOutcome {
273        document_id: opts.document_id.clone(),
274        title: document.title.clone(),
275        revision_id: document.revision_id.clone(),
276        tabs,
277    })
278}
279
280#[cfg(test)]
281#[allow(clippy::unwrap_used, clippy::expect_used)]
282mod tests {
283    use super::*;
284    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
285    use crate::drive::client::DriveClient;
286    use crate::drive::docs::client::{DocsClient, DOCS_API_URL};
287    use crate::drive::docs::structure::ElementKind;
288    use crate::test_support::env::MapEnv;
289    use crate::utils::secret::Secret;
290    use wiremock::matchers::{method, path, query_param};
291    use wiremock::{Mock, MockServer, ResponseTemplate};
292
293    fn test_credentials() -> DriveCredentials {
294        DriveCredentials {
295            client_id: "client-1".to_string(),
296            client_secret: Secret::new("secret-1"),
297            refresh_token: Secret::new("refresh-1"),
298            scope: DriveGrantedScopes::READONLY,
299        }
300    }
301
302    /// Builds a Docs client pointed at wiremock, via the real derivation
303    /// path.
304    ///
305    /// Note the ordering: `replace_session` swaps the Drive client's whole
306    /// transport, so it must run **before** the derive. Deriving first would
307    /// leave the Docs client holding the original session, pointed at the
308    /// real `oauth2.googleapis.com` — a live network call from a unit test.
309    async fn docs_client(server: &MockServer) -> DocsClient {
310        Mock::given(method("POST"))
311            .and(path("/token"))
312            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
313                "access_token": "test-token",
314                "expires_in": 3600,
315            })))
316            .mount(server)
317            .await;
318
319        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
320        crate::drive::client::test_support::replace_session(
321            &mut drive,
322            &test_credentials(),
323            &format!("{}/token", server.uri()),
324        );
325        let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
326        DocsClient::from_drive_client_with(&env, &drive).unwrap()
327    }
328
329    fn opts(tab: Option<&str>) -> ReadOptions {
330        ReadOptions {
331            document_id: "d1".to_string(),
332            tab: tab.map(str::to_string),
333            suggestions: SuggestionsViewMode::default(),
334        }
335    }
336
337    fn paragraph(start: i64, end: i64, text: &str) -> serde_json::Value {
338        serde_json::json!({
339            "startIndex": start, "endIndex": end,
340            "paragraph": {
341                "elements": [{"textRun": {"content": text}}],
342                "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
343            },
344        })
345    }
346
347    fn mount_document(body: serde_json::Value) -> Mock {
348        Mock::given(method("GET"))
349            .and(path("/v1/documents/d1"))
350            .respond_with(ResponseTemplate::new(200).set_body_json(body))
351    }
352
353    #[tokio::test]
354    async fn read_calls_documents_get_exactly_once() {
355        let server = MockServer::start().await;
356        let client = docs_client(&server).await;
357        mount_document(serde_json::json!({
358            "documentId": "d1", "title": "Doc", "revisionId": "rev-1",
359            "body": {"content": [paragraph(1, 10, "hello\n")]},
360        }))
361        .expect(1)
362        .mount(&server)
363        .await;
364
365        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
366        assert_eq!(outcome.tabs.len(), 1);
367        assert_eq!(outcome.tabs[0].elements[0].text, "hello");
368    }
369
370    /// The lease token has to survive the read, or the write phase has
371    /// nothing to present.
372    #[tokio::test]
373    async fn read_carries_the_revision_id_through() {
374        let server = MockServer::start().await;
375        let client = docs_client(&server).await;
376        mount_document(serde_json::json!({
377            "documentId": "d1", "revisionId": "rev-abc",
378            "body": {"content": []},
379        }))
380        .mount(&server)
381        .await;
382
383        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
384        assert_eq!(outcome.revision_id.as_deref(), Some("rev-abc"));
385    }
386
387    /// A reader-only caller gets no `revisionId`; the read still succeeds.
388    #[tokio::test]
389    async fn read_without_edit_access_has_no_revision_id() {
390        let server = MockServer::start().await;
391        let client = docs_client(&server).await;
392        mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
393            .mount(&server)
394            .await;
395
396        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
397        assert_eq!(outcome.revision_id, None);
398    }
399
400    #[tokio::test]
401    async fn read_of_a_tabbed_document_returns_every_tab_in_order() {
402        let server = MockServer::start().await;
403        let client = docs_client(&server).await;
404        mount_document(serde_json::json!({
405            "documentId": "d1", "revisionId": "rev-1",
406            "tabs": [
407                {
408                    "tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
409                    "documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
410                },
411                {
412                    "tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
413                    "documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
414                },
415            ],
416        }))
417        .mount(&server)
418        .await;
419
420        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
421        let ids: Vec<_> = outcome
422            .tabs
423            .iter()
424            .map(|t| t.tab_id.as_deref().unwrap())
425            .collect();
426        assert_eq!(ids, vec!["t.0", "t.1"]);
427        assert_eq!(outcome.tabs[1].elements[0].text, "b");
428    }
429
430    #[tokio::test]
431    async fn read_of_a_legacy_body_document_returns_one_anonymous_tab() {
432        let server = MockServer::start().await;
433        let client = docs_client(&server).await;
434        mount_document(serde_json::json!({
435            "documentId": "d1",
436            "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
437        }))
438        .mount(&server)
439        .await;
440
441        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
442        assert_eq!(outcome.tabs.len(), 1);
443        assert_eq!(outcome.tabs[0].tab_id, None);
444        assert_eq!(outcome.tabs[0].elements[0].kind, ElementKind::SectionBreak);
445    }
446
447    #[tokio::test]
448    async fn read_filters_to_a_single_tab() {
449        let server = MockServer::start().await;
450        let client = docs_client(&server).await;
451        mount_document(serde_json::json!({
452            "documentId": "d1",
453            "tabs": [
454                {
455                    "tabProperties": {"tabId": "t.0", "title": "One"},
456                    "documentTab": {"body": {"content": [paragraph(1, 5, "a\n")]}},
457                },
458                {
459                    "tabProperties": {"tabId": "t.1", "title": "Two"},
460                    "documentTab": {"body": {"content": [paragraph(1, 5, "b\n")]}},
461                },
462            ],
463        }))
464        .mount(&server)
465        .await;
466
467        let outcome = read(&DocsApi::new(&client), &opts(Some("t.1")))
468            .await
469            .unwrap();
470        assert_eq!(outcome.tabs.len(), 1);
471        assert_eq!(outcome.tabs[0].elements[0].text, "b");
472    }
473
474    /// A typo'd tab id and a genuinely empty tab must not look alike.
475    #[tokio::test]
476    async fn read_with_an_unknown_tab_id_errors_and_names_the_real_ids() {
477        let server = MockServer::start().await;
478        let client = docs_client(&server).await;
479        mount_document(serde_json::json!({
480            "documentId": "d1",
481            "tabs": [
482                {"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
483                {"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
484            ],
485        }))
486        .mount(&server)
487        .await;
488
489        let err = read(&DocsApi::new(&client), &opts(Some("nope")))
490            .await
491            .unwrap_err()
492            .to_string();
493        assert!(err.contains("no tab 'nope'"), "{err}");
494        assert!(err.contains("t.0"), "{err}");
495        assert!(err.contains("t.1"), "{err}");
496    }
497
498    /// Asking for a tab of a legacy single-body document explains *why*
499    /// there are none, rather than printing an empty id list.
500    #[tokio::test]
501    async fn read_with_a_tab_filter_on_a_legacy_document_explains_itself() {
502        let server = MockServer::start().await;
503        let client = docs_client(&server).await;
504        mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
505            .mount(&server)
506            .await;
507
508        let err = read(&DocsApi::new(&client), &opts(Some("t.0")))
509            .await
510            .unwrap_err()
511            .to_string();
512        assert!(err.contains("no tabs"), "{err}");
513    }
514
515    #[tokio::test]
516    async fn read_surfaces_an_api_error_with_its_status_and_message() {
517        let server = MockServer::start().await;
518        let client = docs_client(&server).await;
519        Mock::given(method("GET"))
520            .and(path("/v1/documents/d1"))
521            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
522                "error": {"code": 404, "message": "Requested entity was not found.",
523                          "status": "NOT_FOUND"},
524            })))
525            .mount(&server)
526            .await;
527
528        let err = read(&DocsApi::new(&client), &opts(None))
529            .await
530            .unwrap_err()
531            .to_string();
532        assert!(err.contains("Docs API request failed"), "{err}");
533        assert!(err.contains("404"), "{err}");
534        assert!(err.contains("Requested entity was not found."), "{err}");
535    }
536
537    #[tokio::test]
538    async fn read_honours_the_suggestions_view_mode() {
539        let server = MockServer::start().await;
540        let client = docs_client(&server).await;
541        Mock::given(method("GET"))
542            .and(path("/v1/documents/d1"))
543            .and(query_param(
544                "suggestionsViewMode",
545                "PREVIEW_SUGGESTIONS_ACCEPTED",
546            ))
547            .and(query_param("includeTabsContent", "true"))
548            .respond_with(
549                ResponseTemplate::new(200).set_body_json(
550                    serde_json::json!({"documentId": "d1", "body": {"content": []}}),
551                ),
552            )
553            .expect(1)
554            .mount(&server)
555            .await;
556
557        let options = ReadOptions {
558            suggestions: SuggestionsViewMode::PreviewAccepted,
559            ..opts(None)
560        };
561        read(&DocsApi::new(&client), &options).await.unwrap();
562    }
563
564    #[tokio::test]
565    async fn read_outcome_serialises_tabs_as_an_ordered_list() {
566        let server = MockServer::start().await;
567        let client = docs_client(&server).await;
568        mount_document(serde_json::json!({
569            "documentId": "d1", "title": "T", "revisionId": "rev-1",
570            "tabs": [
571                {"tabProperties": {"tabId": "t.0"}, "documentTab": {"body": {"content": []}}},
572                {"tabProperties": {"tabId": "t.1"}, "documentTab": {"body": {"content": []}}},
573            ],
574        }))
575        .mount(&server)
576        .await;
577
578        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
579        let json = serde_json::to_value(&outcome).unwrap();
580        // A list, never a map: tab order is meaningful and map key order is
581        // not guaranteed through serde_json.
582        assert!(json["tabs"].is_array());
583        assert_eq!(json["tabs"][0]["tab_id"], "t.0");
584        assert_eq!(json["revision_id"], "rev-1");
585    }
586
587    #[tokio::test]
588    async fn read_outcome_omits_absent_optional_fields() {
589        let server = MockServer::start().await;
590        let client = docs_client(&server).await;
591        mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
592            .mount(&server)
593            .await;
594
595        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
596        let json = serde_json::to_value(&outcome).unwrap();
597        assert!(json.get("title").is_none());
598        assert!(json.get("revision_id").is_none());
599        assert!(json["tabs"][0].get("tab_id").is_none());
600    }
601
602    /// A two-tab document whose `-o jsonl` rendering the next few tests
603    /// assert against.
604    async fn two_tab_outcome(server: &MockServer) -> ReadOutcome {
605        let client = docs_client(server).await;
606        mount_document(serde_json::json!({
607            "documentId": "d1",
608            "revisionId": "rev-1",
609            "tabs": [
610                {
611                    "tabProperties": {"tabId": "t.0"},
612                    "documentTab": {"body": {"content": [
613                        paragraph(1, 5, "a\n"), paragraph(5, 9, "b\n")]}},
614                },
615                {
616                    "tabProperties": {"tabId": "t.1"},
617                    "documentTab": {"body": {"content": [paragraph(1, 5, "c\n")]}},
618                },
619            ],
620        }))
621        .mount(server)
622        .await;
623
624        read(&DocsApi::new(&client), &opts(None)).await.unwrap()
625    }
626
627    /// Renders `outcome` the way `-o jsonl` does, as the bytes a caller pipes
628    /// into `jq`.
629    ///
630    /// Asserting on the rendered output rather than on `flat_elements()` is
631    /// deliberate: the helper existed and was correct while `write_jsonl`
632    /// still emitted one line for the whole document, so a test of the
633    /// projection alone cannot see the bug that mattered.
634    fn render_jsonl(outcome: &ReadOutcome) -> String {
635        let mut buf = Vec::new();
636        outcome.write_jsonl(&mut buf).unwrap();
637        String::from_utf8(buf).unwrap()
638    }
639
640    /// The `-o jsonl` shape: one record per element, not one per document.
641    #[tokio::test]
642    async fn jsonl_emits_one_line_per_element_across_every_tab() {
643        let server = MockServer::start().await;
644        let outcome = two_tab_outcome(&server).await;
645
646        let rendered = render_jsonl(&outcome);
647        let lines: Vec<&str> = rendered.lines().collect();
648        assert_eq!(lines.len(), 3);
649
650        let texts: Vec<String> = lines
651            .iter()
652            .map(|line| {
653                serde_json::from_str::<serde_json::Value>(line).unwrap()["text"].to_string()
654            })
655            .collect();
656        assert_eq!(texts, vec!["\"a\"", "\"b\"", "\"c\""]);
657    }
658
659    /// Each line carries the document and tab identity, which is what makes
660    /// it self-describing once it is separated from its siblings.
661    #[tokio::test]
662    async fn jsonl_denormalises_document_and_tab_identity_onto_every_line() {
663        let server = MockServer::start().await;
664        let outcome = two_tab_outcome(&server).await;
665        let rendered = render_jsonl(&outcome);
666
667        let lines: Vec<serde_json::Value> = rendered
668            .lines()
669            .map(|line| serde_json::from_str(line).unwrap())
670            .collect();
671
672        for line in &lines {
673            assert_eq!(line["document_id"], "d1");
674            assert_eq!(line["revision_id"], "rev-1");
675        }
676        assert_eq!(lines[0]["tab_id"], "t.0");
677        assert_eq!(lines[1]["tab_id"], "t.0");
678        assert_eq!(lines[2]["tab_id"], "t.1");
679    }
680
681    /// The element's own fields sit at the top level, not nested under a
682    /// key — `jq -r '.text'` is the documented filter, and a wrapper object
683    /// would silently make it yield `null`.
684    #[tokio::test]
685    async fn jsonl_flattens_element_fields_to_the_top_level() {
686        let server = MockServer::start().await;
687        let outcome = two_tab_outcome(&server).await;
688        let rendered = render_jsonl(&outcome);
689
690        let first: serde_json::Value =
691            serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
692        assert_eq!(first["text"], "a");
693        assert_eq!(first["kind"], "paragraph");
694        assert_eq!(first["start_index"], 1);
695        assert_eq!(first["end_index"], 5);
696        assert!(first.get("element").is_none());
697    }
698
699    /// A read-only caller has no `revisionId`, and the field is omitted
700    /// rather than rendered as `null` — the same contract `-o json` keeps.
701    #[tokio::test]
702    async fn jsonl_omits_revision_and_tab_when_absent() {
703        let server = MockServer::start().await;
704        let client = docs_client(&server).await;
705        mount_document(serde_json::json!({
706            "documentId": "d1",
707            "body": {"content": [paragraph(1, 5, "a\n")]},
708        }))
709        .mount(&server)
710        .await;
711
712        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
713        let rendered = render_jsonl(&outcome);
714        let line: serde_json::Value =
715            serde_json::from_str(rendered.lines().next().unwrap()).unwrap();
716
717        assert_eq!(line["document_id"], "d1");
718        assert!(line.get("revision_id").is_none());
719        assert!(line.get("tab_id").is_none());
720    }
721
722    /// An empty document renders no lines at all, rather than one line
723    /// describing a document with nothing in it.
724    #[tokio::test]
725    async fn jsonl_emits_nothing_for_a_document_with_no_elements() {
726        let server = MockServer::start().await;
727        let client = docs_client(&server).await;
728        mount_document(serde_json::json!({"documentId": "d1", "body": {"content": []}}))
729            .mount(&server)
730            .await;
731
732        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
733        assert_eq!(render_jsonl(&outcome), "");
734    }
735
736    /// A document response with headers, footers and footnotes is no longer
737    /// a silent drop — this is the read half of issue #1799.
738    #[tokio::test]
739    async fn read_populates_headers_footers_and_footnotes() {
740        let server = MockServer::start().await;
741        let client = docs_client(&server).await;
742        mount_document(serde_json::json!({
743            "documentId": "d1",
744            "body": {"content": [paragraph(1, 5, "body\n")]},
745            "headers": {"h1": {"content": [paragraph(0, 8, "Header\n")]}},
746            "footers": {"f1": {"content": [paragraph(0, 8, "Footer\n")]}},
747            "footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
748        }))
749        .mount(&server)
750        .await;
751
752        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
753        let tab = &outcome.tabs[0];
754        assert_eq!(tab.headers.len(), 1);
755        assert_eq!(tab.headers[0].segment_id, "h1");
756        assert_eq!(tab.headers[0].elements[0].text, "Header");
757        assert_eq!(tab.footers[0].elements[0].text, "Footer");
758        assert_eq!(tab.footnotes[0].elements[0].text, "Note");
759    }
760
761    /// A document with none of these segments still round-trips exactly as
762    /// before: the new fields are omitted, not emitted as empty arrays.
763    #[tokio::test]
764    async fn a_document_with_no_segments_serialises_without_the_new_fields() {
765        let server = MockServer::start().await;
766        let client = docs_client(&server).await;
767        mount_document(serde_json::json!({
768            "documentId": "d1",
769            "body": {"content": [paragraph(1, 5, "a\n")]},
770        }))
771        .mount(&server)
772        .await;
773
774        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
775        let json = serde_json::to_value(&outcome).unwrap();
776        let tab = &json["tabs"][0];
777        assert!(tab.get("headers").is_none());
778        assert!(tab.get("footers").is_none());
779        assert!(tab.get("footnotes").is_none());
780    }
781
782    /// `-o jsonl` carries segment identity on each of its lines, and leaves
783    /// it off body lines rather than emitting `null`.
784    #[tokio::test]
785    async fn jsonl_carries_segment_identity_for_segment_elements() {
786        let server = MockServer::start().await;
787        let client = docs_client(&server).await;
788        mount_document(serde_json::json!({
789            "documentId": "d1",
790            "body": {"content": [paragraph(1, 5, "body\n")]},
791            "footnotes": {"n1": {"content": [paragraph(0, 5, "Note\n")]}},
792        }))
793        .mount(&server)
794        .await;
795
796        let outcome = read(&DocsApi::new(&client), &opts(None)).await.unwrap();
797        let rendered = render_jsonl(&outcome);
798        let lines: Vec<serde_json::Value> = rendered
799            .lines()
800            .map(|line| serde_json::from_str(line).unwrap())
801            .collect();
802
803        assert_eq!(lines.len(), 2);
804        assert!(lines[0].get("segment").is_none());
805        assert_eq!(lines[1]["segment"]["kind"], "footnote");
806        assert_eq!(lines[1]["segment"]["segment_id"], "n1");
807        assert_eq!(lines[1]["text"], "Note");
808    }
809}