Skip to main content

omni_dev/drive/docs/
types.rs

1//! Wire types for the Docs v1 REST API.
2//!
3//! Field naming follows Docs' camelCase JSON via per-field
4//! `#[serde(rename = "...")]`, mirroring `src/drive/types.rs` and
5//! `src/drive/sheets/types.rs`. Only the subset the CLI actually renders is
6//! modelled; every unmodelled field is tolerated and dropped, so a Google
7//! response gaining a field never breaks a parse.
8//!
9//! Four shapes here are load-bearing and easy to get wrong:
10//!
11//! - **`startIndex` is absent, not `0`, for the first element.** Docs
12//!   serialises proto3 JSON, which omits zero-valued integers, so every real
13//!   `documents.get` has `body.content[0]` (the leading `sectionBreak`) with
14//!   no `startIndex` key at all. Every index field is therefore
15//!   `Option<i64>`, read through the `start_index()`/`end_index()`
16//!   accessors. This is the direct analogue of `ValueRange::values` being
17//!   absent-not-empty for a blank sheet.
18//! - **Indices are UTF-16 code units, and `endIndex` is exclusive.** Not
19//!   chars, not bytes. Any code deriving an index from a `&str` must use
20//!   `s.encode_utf16().count()`. The difference is invisible in ASCII and
21//!   wrong the moment a document contains an emoji or a CJK astral
22//!   character, so nothing in this crate computes one — see
23//!   `crate::drive::docs::structure`.
24//! - **An empty message still serialises as `{}` when present.** A
25//!   `sectionBreak` with no fields set arrives as `"sectionBreak": {}`, so
26//!   the union variants this module does not inspect are modelled as
27//!   `Option<serde_json::Value>`: `Some(Object({}))` when present, `None`
28//!   when absent, which is exactly the discriminator semantics needed.
29//! - **`tabs` and `body` are mutually exclusive.** With
30//!   `includeTabsContent=true` the content lives under `tabs[]` and the
31//!   top-level `body` is absent; without it, only `body` is populated. That
32//!   is normalised once, in [`Document::resolved_tabs`], rather than at
33//!   every call site.
34
35use std::collections::HashMap;
36
37use serde::{Deserialize, Serialize};
38
39/// A document's full structural model, from `documents.get`.
40///
41/// Deliberately **not** `fields`-masked when fetched — unlike
42/// `spreadsheets.get`, whose mask exists to keep every cell out of the
43/// response. A Docs mask has to spell nesting depth out literally, and a
44/// table may contain a table to arbitrary depth, so any fixed-depth mask
45/// silently drops document text below its deepest named level. See
46/// `crate::drive::docs::api`.
47#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
48pub struct Document {
49    /// The document's id (echoes the one requested).
50    #[serde(
51        default,
52        skip_serializing_if = "Option::is_none",
53        rename = "documentId"
54    )]
55    pub document_id: Option<String>,
56    /// The document's display title.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub title: Option<String>,
59    /// The revision this response reflects.
60    ///
61    /// Google populates this **only for callers with edit access**, so a
62    /// read-only caller sees `None`. It is the `writeControl`
63    /// `requiredRevisionId` token a later `documents.batchUpdate` presents
64    /// to refuse a write against a document that moved underneath it.
65    #[serde(
66        default,
67        skip_serializing_if = "Option::is_none",
68        rename = "revisionId"
69    )]
70    pub revision_id: Option<String>,
71    /// Legacy single-tab content, populated only when the request did not
72    /// ask for tab content. Mutually exclusive with [`Self::tabs`].
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub body: Option<Body>,
75    /// The tab tree, populated only when `includeTabsContent=true`.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub tabs: Vec<Tab>,
78    /// Named ranges, keyed by name.
79    ///
80    /// The *stable* way to address a region: an index shifts on every
81    /// insertion, a named range's name does not.
82    #[serde(
83        default,
84        skip_serializing_if = "HashMap::is_empty",
85        rename = "namedRanges"
86    )]
87    pub named_ranges: HashMap<String, NamedRanges>,
88    /// Page headers, keyed by `segmentId`. Legacy shape only — see
89    /// [`Self::resolved_tabs`].
90    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91    pub headers: HashMap<String, Header>,
92    /// Page footers, keyed by `segmentId`. Legacy shape only.
93    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94    pub footers: HashMap<String, Footer>,
95    /// Footnotes, keyed by `segmentId`. Legacy shape only.
96    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97    pub footnotes: HashMap<String, Footnote>,
98}
99
100/// One header, footer or footnote segment, resolved and ready to flatten.
101///
102/// The map key Google addresses it by (a `segmentId`) is carried alongside
103/// its content rather than discarded, since it is the only thing that
104/// distinguishes e.g. two headers on the same tab from one another.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct ResolvedSegment<'a> {
107    /// The segment's id — the map key it is stored under.
108    pub segment_id: &'a str,
109    /// The segment's structural elements, in index order.
110    pub content: &'a [StructuralElement],
111}
112
113/// One tab's content, with the legacy single-`body` response normalised into
114/// the same shape so callers never branch on which form arrived.
115///
116/// Header/footer/footnote content is resolved **lazily**, via
117/// [`Self::headers`]/[`Self::footers`]/[`Self::footnotes`], rather than
118/// eagerly at construction: `docs info`'s outline and the write-preview
119/// corpus builders only ever read [`Self::body`], and paying the sort for
120/// a footnote-heavy document they discard immediately would be pure waste.
121/// Only `docs read` calls the accessors.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct ResolvedTab<'a> {
124    /// The tab's id, or `None` for a legacy single-body document.
125    pub tab_id: Option<&'a str>,
126    /// The tab's title, or `None` for a legacy single-body document.
127    pub title: Option<&'a str>,
128    /// Depth in the tab tree; `0` for a top-level tab.
129    pub nesting_level: i64,
130    /// The tab's body, when it has one.
131    pub body: Option<&'a Body>,
132    headers_map: Option<&'a HashMap<String, Header>>,
133    footers_map: Option<&'a HashMap<String, Footer>>,
134    footnotes_map: Option<&'a HashMap<String, Footnote>>,
135}
136
137impl<'a> ResolvedTab<'a> {
138    /// The tab's page headers, resolved and sorted by `segment_id` — map
139    /// iteration order is not deterministic.
140    #[must_use]
141    pub fn headers(&self) -> Vec<ResolvedSegment<'a>> {
142        self.headers_map
143            .map_or_else(Vec::new, |m| resolve_segments(m, |h| h.content.as_slice()))
144    }
145    /// The tab's page footers, likewise resolved and sorted.
146    #[must_use]
147    pub fn footers(&self) -> Vec<ResolvedSegment<'a>> {
148        self.footers_map
149            .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
150    }
151    /// The tab's footnotes, likewise resolved and sorted.
152    #[must_use]
153    pub fn footnotes(&self) -> Vec<ResolvedSegment<'a>> {
154        self.footnotes_map
155            .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
156    }
157}
158
159/// Resolves one segment map into sorted [`ResolvedSegment`]s.
160fn resolve_segments<'a, T>(
161    map: &'a HashMap<String, T>,
162    content: impl Fn(&'a T) -> &'a [StructuralElement],
163) -> Vec<ResolvedSegment<'a>> {
164    let mut out: Vec<ResolvedSegment<'a>> = map
165        .iter()
166        .map(|(id, seg)| ResolvedSegment {
167            segment_id: id.as_str(),
168            content: content(seg),
169        })
170        .collect();
171    out.sort_by_key(|s| s.segment_id);
172    out
173}
174
175impl Document {
176    /// The document title, or `""` when absent.
177    #[must_use]
178    pub fn title(&self) -> &str {
179        self.title.as_deref().unwrap_or("")
180    }
181
182    /// Every tab's content, depth-first through `childTabs`, falling back to
183    /// the legacy top-level [`Self::body`] (and its sibling
184    /// `headers`/`footers`/`footnotes` maps) as a single anonymous tab.
185    ///
186    /// The fallback is what makes the `tabs`-xor-`body` split invisible to
187    /// callers. A document fetched without `includeTabsContent` yields
188    /// exactly one `ResolvedTab` whose `tab_id` and `title` are `None` —
189    /// unless the document has neither body content nor any segment, in
190    /// which case there is nothing to synthesise a tab for.
191    #[must_use]
192    pub fn resolved_tabs(&self) -> Vec<ResolvedTab<'_>> {
193        if self.tabs.is_empty() {
194            if self.body.is_none()
195                && self.headers.is_empty()
196                && self.footers.is_empty()
197                && self.footnotes.is_empty()
198            {
199                return Vec::new();
200            }
201            return vec![ResolvedTab {
202                tab_id: None,
203                title: None,
204                nesting_level: 0,
205                body: self.body.as_ref(),
206                headers_map: Some(&self.headers),
207                footers_map: Some(&self.footers),
208                footnotes_map: Some(&self.footnotes),
209            }];
210        }
211        let mut out = Vec::new();
212        for tab in &self.tabs {
213            push_tab(tab, 0, &mut out);
214        }
215        out
216    }
217}
218
219/// Walks one tab and its `childTabs` depth-first, parent before children.
220fn push_tab<'a>(tab: &'a Tab, depth: i64, out: &mut Vec<ResolvedTab<'a>>) {
221    let props = tab.tab_properties.as_ref();
222    let doc_tab = tab.document_tab.as_ref();
223    out.push(ResolvedTab {
224        tab_id: props.and_then(|p| p.tab_id.as_deref()),
225        title: props.and_then(|p| p.title.as_deref()),
226        // Prefer the server's own nesting level when it sent one; fall back
227        // to the walk depth, which agrees with it for every well-formed
228        // response and keeps a truncated one self-consistent.
229        nesting_level: props.and_then(|p| p.nesting_level).unwrap_or(depth),
230        body: doc_tab.and_then(|dt| dt.body.as_ref()),
231        headers_map: doc_tab.map(|dt| &dt.headers),
232        footers_map: doc_tab.map(|dt| &dt.footers),
233        footnotes_map: doc_tab.map(|dt| &dt.footnotes),
234    });
235    for child in &tab.child_tabs {
236        push_tab(child, depth + 1, out);
237    }
238}
239
240/// One tab of a document.
241#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
242pub struct Tab {
243    /// This tab's identity and position.
244    #[serde(
245        default,
246        skip_serializing_if = "Option::is_none",
247        rename = "tabProperties"
248    )]
249    pub tab_properties: Option<TabProperties>,
250    /// The tab's content, when it is a document tab.
251    #[serde(
252        default,
253        skip_serializing_if = "Option::is_none",
254        rename = "documentTab"
255    )]
256    pub document_tab: Option<DocumentTab>,
257    /// Nested tabs, recursively.
258    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "childTabs")]
259    pub child_tabs: Vec<Self>,
260}
261
262/// A tab's identity and position within the tab tree.
263#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
264pub struct TabProperties {
265    /// The tab's id — a required component of every Docs edit address in a
266    /// multi-tab document.
267    #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
268    pub tab_id: Option<String>,
269    /// The tab's display title.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub title: Option<String>,
272    /// Zero-based position among its siblings.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub index: Option<i64>,
275    /// Depth in the tab tree; `0` for a top-level tab.
276    #[serde(
277        default,
278        skip_serializing_if = "Option::is_none",
279        rename = "nestingLevel"
280    )]
281    pub nesting_level: Option<i64>,
282    /// The parent tab's id, absent for a top-level tab.
283    #[serde(
284        default,
285        skip_serializing_if = "Option::is_none",
286        rename = "parentTabId"
287    )]
288    pub parent_tab_id: Option<String>,
289}
290
291/// The document-flavoured content of a tab.
292#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
293pub struct DocumentTab {
294    /// The tab's body.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub body: Option<Body>,
297    /// Named ranges scoped to this tab.
298    #[serde(
299        default,
300        skip_serializing_if = "HashMap::is_empty",
301        rename = "namedRanges"
302    )]
303    pub named_ranges: HashMap<String, NamedRanges>,
304    /// Page headers, keyed by `segmentId`.
305    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
306    pub headers: HashMap<String, Header>,
307    /// Page footers, keyed by `segmentId`.
308    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
309    pub footers: HashMap<String, Footer>,
310    /// Footnotes, keyed by `segmentId`.
311    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
312    pub footnotes: HashMap<String, Footnote>,
313}
314
315/// A document body — the main index segment.
316///
317/// Headers, footers and footnotes live in their own segments, addressed by
318/// `segmentId`. They are modelled as sibling maps on [`Document`] and
319/// [`DocumentTab`] ([`Header`], [`Footer`], [`Footnote`]) rather than folded
320/// into this type, and normalised onto [`ResolvedTab::headers`],
321/// `.footers` and `.footnotes` by [`Document::resolved_tabs`] the same way
322/// `body` is normalised here.
323#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
324pub struct Body {
325    /// The body's structural elements, in index order.
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub content: Vec<StructuralElement>,
328}
329
330/// One page header — a segment addressed by the map key it is stored under.
331#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
332pub struct Header {
333    /// The header's structural elements, in index order.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub content: Vec<StructuralElement>,
336}
337
338/// One page footer — same shape as [`Header`]; Google models these as
339/// distinct message types even though the field is identical, so this
340/// mirrors that instead of aliasing.
341#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
342pub struct Footer {
343    /// The footer's structural elements, in index order.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub content: Vec<StructuralElement>,
346}
347
348/// One footnote body — same shape again. The footnote's own id is the map
349/// key it is stored under, not a field on this struct.
350#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
351pub struct Footnote {
352    /// The footnote's structural elements, in index order.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub content: Vec<StructuralElement>,
355}
356
357/// One structural element: exactly one of the variant fields is present, and
358/// which one is the discriminator.
359#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
360pub struct StructuralElement {
361    /// Inclusive start, in UTF-16 code units. Absent means `0`.
362    #[serde(
363        default,
364        skip_serializing_if = "Option::is_none",
365        rename = "startIndex"
366    )]
367    pub start_index: Option<i64>,
368    /// Exclusive end, in UTF-16 code units.
369    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
370    pub end_index: Option<i64>,
371    /// A paragraph.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub paragraph: Option<Paragraph>,
374    /// A table.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub table: Option<Table>,
377    /// A table of contents.
378    #[serde(
379        default,
380        skip_serializing_if = "Option::is_none",
381        rename = "tableOfContents"
382    )]
383    pub table_of_contents: Option<TableOfContents>,
384    /// A section break. Carries nothing this crate reads, so it is kept as
385    /// raw JSON purely as a present/absent discriminator.
386    #[serde(
387        default,
388        skip_serializing_if = "Option::is_none",
389        rename = "sectionBreak"
390    )]
391    pub section_break: Option<serde_json::Value>,
392}
393
394impl StructuralElement {
395    /// Inclusive start index, treating an absent field as `0`.
396    #[must_use]
397    pub fn start_index(&self) -> i64 {
398        self.start_index.unwrap_or(0)
399    }
400
401    /// Exclusive end index, treating an absent field as `0`.
402    #[must_use]
403    pub fn end_index(&self) -> i64 {
404        self.end_index.unwrap_or(0)
405    }
406}
407
408/// A paragraph and its inline elements.
409#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
410pub struct Paragraph {
411    /// The paragraph's inline elements, in index order.
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub elements: Vec<ParagraphElement>,
414    /// The paragraph's style, notably its `namedStyleType`.
415    #[serde(
416        default,
417        skip_serializing_if = "Option::is_none",
418        rename = "paragraphStyle"
419    )]
420    pub paragraph_style: Option<ParagraphStyle>,
421    /// Present when the paragraph is a list item.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub bullet: Option<Bullet>,
424}
425
426impl Paragraph {
427    /// The paragraph's text, concatenating every `textRun` in order.
428    ///
429    /// Concatenation is not optional: a paragraph with one bolded word is
430    /// three text runs, and reading only the first silently truncates it.
431    ///
432    /// The trailing `\n` every Docs paragraph carries is **kept** here —
433    /// stripping is the renderer's job, because the newline occupies one
434    /// index unit and dropping it from the text without dropping it from
435    /// `end_index` is what makes the two disagree.
436    #[must_use]
437    pub fn text(&self) -> String {
438        self.elements
439            .iter()
440            .filter_map(|el| el.text_run.as_ref())
441            .map(|run| run.content.as_str())
442            .collect()
443    }
444
445    /// The paragraph's `namedStyleType` (`HEADING_1`, `NORMAL_TEXT`, …).
446    #[must_use]
447    pub fn named_style_type(&self) -> Option<&str> {
448        self.paragraph_style
449            .as_ref()
450            .and_then(|style| style.named_style_type.as_deref())
451    }
452}
453
454/// A paragraph's style.
455#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
456pub struct ParagraphStyle {
457    /// `HEADING_1`, `TITLE`, `NORMAL_TEXT`, …
458    #[serde(
459        default,
460        skip_serializing_if = "Option::is_none",
461        rename = "namedStyleType"
462    )]
463    pub named_style_type: Option<String>,
464    /// The heading's id, when this paragraph is a heading.
465    #[serde(default, skip_serializing_if = "Option::is_none", rename = "headingId")]
466    pub heading_id: Option<String>,
467}
468
469/// A list-item marker.
470#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
471pub struct Bullet {
472    /// The list this item belongs to.
473    #[serde(default, skip_serializing_if = "Option::is_none", rename = "listId")]
474    pub list_id: Option<String>,
475    /// Nesting depth within that list.
476    #[serde(
477        default,
478        skip_serializing_if = "Option::is_none",
479        rename = "nestingLevel"
480    )]
481    pub nesting_level: Option<i64>,
482}
483
484/// One inline element of a paragraph.
485///
486/// Only `textRun` carries text this crate reads. The rest are modelled as
487/// present/absent so an element that consumes index space without
488/// contributing text — an inline image, a page break — is not mistaken for
489/// nothing at all.
490#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
491pub struct ParagraphElement {
492    /// Inclusive start, in UTF-16 code units. Absent means `0`.
493    #[serde(
494        default,
495        skip_serializing_if = "Option::is_none",
496        rename = "startIndex"
497    )]
498    pub start_index: Option<i64>,
499    /// Exclusive end, in UTF-16 code units.
500    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
501    pub end_index: Option<i64>,
502    /// A run of text with uniform styling.
503    #[serde(default, skip_serializing_if = "Option::is_none", rename = "textRun")]
504    pub text_run: Option<TextRun>,
505    /// An inline image or drawing. Consumes one index unit, contributes no
506    /// text — so its paragraph's text is shorter than its index span, which
507    /// is correct and must not be "fixed".
508    #[serde(
509        default,
510        skip_serializing_if = "Option::is_none",
511        rename = "inlineObjectElement"
512    )]
513    pub inline_object_element: Option<InlineObjectElement>,
514    /// A page break.
515    #[serde(default, skip_serializing_if = "Option::is_none", rename = "pageBreak")]
516    pub page_break: Option<serde_json::Value>,
517    /// A column break.
518    #[serde(
519        default,
520        skip_serializing_if = "Option::is_none",
521        rename = "columnBreak"
522    )]
523    pub column_break: Option<serde_json::Value>,
524    /// A horizontal rule.
525    #[serde(
526        default,
527        skip_serializing_if = "Option::is_none",
528        rename = "horizontalRule"
529    )]
530    pub horizontal_rule: Option<serde_json::Value>,
531    /// A footnote reference.
532    #[serde(
533        default,
534        skip_serializing_if = "Option::is_none",
535        rename = "footnoteReference"
536    )]
537    pub footnote_reference: Option<serde_json::Value>,
538    /// Auto-text such as a page number.
539    #[serde(default, skip_serializing_if = "Option::is_none", rename = "autoText")]
540    pub auto_text: Option<serde_json::Value>,
541    /// An equation.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub equation: Option<serde_json::Value>,
544    /// A person mention.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub person: Option<serde_json::Value>,
547    /// A rich link chip.
548    #[serde(default, skip_serializing_if = "Option::is_none", rename = "richLink")]
549    pub rich_link: Option<serde_json::Value>,
550}
551
552/// A run of text with uniform styling.
553#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
554pub struct TextRun {
555    /// The run's text, including any trailing newline.
556    #[serde(default)]
557    pub content: String,
558}
559
560/// A reference to an inline object.
561#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
562pub struct InlineObjectElement {
563    /// The object's id, resolvable against the document's `inlineObjects`.
564    #[serde(
565        default,
566        skip_serializing_if = "Option::is_none",
567        rename = "inlineObjectId"
568    )]
569    pub inline_object_id: Option<String>,
570}
571
572/// A table.
573#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
574pub struct Table {
575    /// Row count.
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub rows: Option<i64>,
578    /// Column count.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub columns: Option<i64>,
581    /// The table's rows.
582    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableRows")]
583    pub table_rows: Vec<TableRow>,
584}
585
586/// One row of a table.
587#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
588pub struct TableRow {
589    /// Inclusive start, in UTF-16 code units.
590    #[serde(
591        default,
592        skip_serializing_if = "Option::is_none",
593        rename = "startIndex"
594    )]
595    pub start_index: Option<i64>,
596    /// Exclusive end, in UTF-16 code units.
597    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
598    pub end_index: Option<i64>,
599    /// The row's cells.
600    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableCells")]
601    pub table_cells: Vec<TableCell>,
602}
603
604/// One cell of a table row.
605#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
606pub struct TableCell {
607    /// Inclusive start, in UTF-16 code units.
608    #[serde(
609        default,
610        skip_serializing_if = "Option::is_none",
611        rename = "startIndex"
612    )]
613    pub start_index: Option<i64>,
614    /// Exclusive end, in UTF-16 code units.
615    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
616    pub end_index: Option<i64>,
617    /// The cell's own structural elements — where the recursion lives, since
618    /// a cell may contain another table.
619    #[serde(default, skip_serializing_if = "Vec::is_empty")]
620    pub content: Vec<StructuralElement>,
621}
622
623/// A table of contents.
624#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
625pub struct TableOfContents {
626    /// The generated entries, as structural elements.
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub content: Vec<StructuralElement>,
629}
630
631/// Every named range sharing one name.
632#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
633pub struct NamedRanges {
634    /// The shared name.
635    #[serde(default)]
636    pub name: String,
637    /// The ranges carrying it.
638    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "namedRanges")]
639    pub named_ranges: Vec<NamedRange>,
640}
641
642/// One named range.
643#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
644pub struct NamedRange {
645    /// The range's id.
646    #[serde(
647        default,
648        skip_serializing_if = "Option::is_none",
649        rename = "namedRangeId"
650    )]
651    pub named_range_id: Option<String>,
652    /// The range's name.
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub name: Option<String>,
655    /// The index spans it covers.
656    #[serde(default, skip_serializing_if = "Vec::is_empty")]
657    pub ranges: Vec<Range>,
658}
659
660/// An index span within one segment of one tab.
661#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
662pub struct Range {
663    /// The segment; empty/absent means the document body.
664    #[serde(default, skip_serializing_if = "Option::is_none", rename = "segmentId")]
665    pub segment_id: Option<String>,
666    /// The tab; empty/absent means the first tab.
667    #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
668    pub tab_id: Option<String>,
669    /// Inclusive start, in UTF-16 code units.
670    #[serde(
671        default,
672        skip_serializing_if = "Option::is_none",
673        rename = "startIndex"
674    )]
675    pub start_index: Option<i64>,
676    /// Exclusive end, in UTF-16 code units.
677    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
678    pub end_index: Option<i64>,
679}
680
681#[cfg(test)]
682#[allow(clippy::unwrap_used, clippy::expect_used)]
683mod tests {
684    use super::*;
685
686    fn parse(json: serde_json::Value) -> Document {
687        serde_json::from_value(json).unwrap()
688    }
689
690    /// The single highest-value test in this file. Docs serialises proto3
691    /// JSON, which omits zero-valued integers, so the first element of every
692    /// real document has no `startIndex` key. Reading it as anything but `0`
693    /// shifts every reported index.
694    #[test]
695    fn a_missing_start_index_means_zero() {
696        let doc = parse(serde_json::json!({
697            "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
698        }));
699        let el = &doc.body.as_ref().unwrap().content[0];
700        assert_eq!(el.start_index, None, "the key really is absent");
701        assert_eq!(el.start_index(), 0, "and the accessor reads it as zero");
702        assert_eq!(el.end_index(), 1);
703    }
704
705    #[test]
706    fn a_present_but_empty_section_break_is_still_recognised() {
707        let doc = parse(serde_json::json!({
708            "body": {"content": [{"sectionBreak": {}}]},
709        }));
710        let el = &doc.body.as_ref().unwrap().content[0];
711        assert!(el.section_break.is_some(), "present-and-empty is Some");
712        assert!(el.paragraph.is_none());
713    }
714
715    #[test]
716    fn an_absent_section_break_is_none() {
717        let doc = parse(serde_json::json!({
718            "body": {"content": [{"paragraph": {"elements": []}}]},
719        }));
720        assert!(doc.body.as_ref().unwrap().content[0]
721            .section_break
722            .is_none());
723    }
724
725    #[test]
726    fn text_run_content_defaults_to_empty() {
727        let run: TextRun = serde_json::from_value(serde_json::json!({})).unwrap();
728        assert_eq!(run.content, "");
729    }
730
731    /// A paragraph with a bolded word is several text runs. Reading only the
732    /// first silently truncates it.
733    #[test]
734    fn paragraph_text_joins_every_text_run_in_order() {
735        let para: Paragraph = serde_json::from_value(serde_json::json!({
736            "elements": [
737                {"textRun": {"content": "Hello "}},
738                {"textRun": {"content": "bold"}},
739                {"textRun": {"content": " world\n"}},
740            ],
741        }))
742        .unwrap();
743        assert_eq!(para.text(), "Hello bold world\n");
744    }
745
746    /// An inline image consumes index space but contributes no text, so a
747    /// paragraph's text is shorter than its index span. That is correct.
748    #[test]
749    fn paragraph_text_skips_a_non_text_element() {
750        let para: Paragraph = serde_json::from_value(serde_json::json!({
751            "elements": [
752                {"textRun": {"content": "a"}},
753                {"inlineObjectElement": {"inlineObjectId": "kix.1"}},
754                {"textRun": {"content": "b\n"}},
755            ],
756        }))
757        .unwrap();
758        assert_eq!(para.text(), "ab\n");
759        assert!(para.elements[1].inline_object_element.is_some());
760    }
761
762    #[test]
763    fn resolved_tabs_synthesises_one_anonymous_tab_from_a_legacy_body() {
764        let doc = parse(serde_json::json!({
765            "title": "Legacy",
766            "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
767        }));
768        let tabs = doc.resolved_tabs();
769        assert_eq!(tabs.len(), 1);
770        assert_eq!(tabs[0].tab_id, None);
771        assert_eq!(tabs[0].title, None);
772        assert_eq!(tabs[0].nesting_level, 0);
773        assert!(tabs[0].body.is_some());
774    }
775
776    #[test]
777    fn resolved_tabs_of_a_document_with_neither_body_nor_tabs_is_empty() {
778        assert!(parse(serde_json::json!({"title": "Empty"}))
779            .resolved_tabs()
780            .is_empty());
781    }
782
783    /// The legacy single-body shape carries its segments at the top level.
784    #[test]
785    fn resolved_tabs_resolves_top_level_segments_for_a_legacy_body() {
786        let doc = parse(serde_json::json!({
787            "body": {"content": []},
788            "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
789            "footers": {"f1": {"content": []}},
790            "footnotes": {"fn1": {"content": []}},
791        }));
792        let tabs = doc.resolved_tabs();
793        assert_eq!(tabs[0].headers().len(), 1);
794        assert_eq!(tabs[0].headers()[0].segment_id, "h1");
795        assert_eq!(tabs[0].footers().len(), 1);
796        assert_eq!(tabs[0].footnotes().len(), 1);
797    }
798
799    /// A tab with none of these maps yields empty `Vec`s, not an error.
800    #[test]
801    fn resolved_tabs_has_empty_segments_when_the_document_has_none() {
802        let doc = parse(serde_json::json!({"body": {"content": []}}));
803        let tabs = doc.resolved_tabs();
804        assert!(tabs[0].headers().is_empty());
805        assert!(tabs[0].footers().is_empty());
806        assert!(tabs[0].footnotes().is_empty());
807    }
808
809    /// A document with no `tabs` and no `body` but a populated segment map
810    /// still yields a tab to hang that segment off, rather than silently
811    /// discarding fetched content because the legacy fallback keyed only on
812    /// `body`'s presence.
813    #[test]
814    fn resolved_tabs_synthesises_a_tab_for_segments_with_no_body() {
815        let doc = parse(serde_json::json!({
816            "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
817        }));
818        let tabs = doc.resolved_tabs();
819        assert_eq!(tabs.len(), 1);
820        assert!(tabs[0].body.is_none());
821        assert_eq!(tabs[0].headers().len(), 1);
822    }
823
824    /// Segments are tab-scoped in the `tabs` shape, and resolved sorted by
825    /// `segment_id` since map iteration order is not guaranteed.
826    #[test]
827    fn resolved_tabs_resolves_tab_scoped_segments_sorted_by_segment_id() {
828        let doc = parse(serde_json::json!({
829            "tabs": [{
830                "tabProperties": {"tabId": "t.0"},
831                "documentTab": {
832                    "body": {"content": []},
833                    "headers": {
834                        "h2": {"content": []},
835                        "h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
836                    },
837                },
838            }],
839        }));
840        let tabs = doc.resolved_tabs();
841        let headers = tabs[0].headers();
842        let ids: Vec<_> = headers.iter().map(|s| s.segment_id).collect();
843        assert_eq!(ids, vec!["h1", "h2"]);
844        assert_eq!(headers[0].content.len(), 1);
845    }
846
847    #[test]
848    fn resolved_tabs_walks_child_tabs_depth_first_parent_before_children() {
849        let doc = parse(serde_json::json!({
850            "tabs": [
851                {
852                    "tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
853                    "documentTab": {"body": {"content": []}},
854                    "childTabs": [{
855                        "tabProperties": {"tabId": "t.0.a", "title": "One A", "nestingLevel": 1},
856                        "documentTab": {"body": {"content": []}},
857                    }],
858                },
859                {
860                    "tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
861                    "documentTab": {"body": {"content": []}},
862                },
863            ],
864        }));
865        let ids: Vec<_> = doc.resolved_tabs().iter().map(|t| t.tab_id).collect();
866        assert_eq!(ids, vec![Some("t.0"), Some("t.0.a"), Some("t.1")]);
867        assert_eq!(doc.resolved_tabs()[1].nesting_level, 1);
868    }
869
870    /// A response that omits `nestingLevel` still gets a self-consistent
871    /// depth from the walk, rather than collapsing every tab to level 0.
872    #[test]
873    fn resolved_tabs_falls_back_to_walk_depth_when_nesting_level_is_absent() {
874        let doc = parse(serde_json::json!({
875            "tabs": [{
876                "tabProperties": {"tabId": "t.0"},
877                "childTabs": [{"tabProperties": {"tabId": "t.0.a"}}],
878            }],
879        }));
880        let tabs = doc.resolved_tabs();
881        assert_eq!(tabs[0].nesting_level, 0);
882        assert_eq!(tabs[1].nesting_level, 1);
883    }
884
885    /// `tabs` wins over `body`; they never both contribute.
886    #[test]
887    fn tabs_take_precedence_over_a_legacy_body() {
888        let doc = parse(serde_json::json!({
889            "body": {"content": [{"paragraph": {"elements": [{"textRun": {"content": "legacy\n"}}]}}]},
890            "tabs": [{
891                "tabProperties": {"tabId": "t.0"},
892                "documentTab": {"body": {"content": []}},
893            }],
894        }));
895        let tabs = doc.resolved_tabs();
896        assert_eq!(tabs.len(), 1);
897        assert_eq!(tabs[0].tab_id, Some("t.0"));
898    }
899
900    #[test]
901    fn nested_tables_parse_recursively() {
902        let doc = parse(serde_json::json!({
903            "body": {"content": [{
904                "startIndex": 1, "endIndex": 40,
905                "table": {
906                    "rows": 1, "columns": 1,
907                    "tableRows": [{"tableCells": [{"content": [{
908                        "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
909                            "content": [{"paragraph": {"elements": [
910                                {"textRun": {"content": "deep\n"}}]}}],
911                        }]}]},
912                    }]}]}],
913                },
914            }]},
915        }));
916        let outer = doc.body.as_ref().unwrap().content[0]
917            .table
918            .as_ref()
919            .unwrap();
920        let inner = outer.table_rows[0].table_cells[0].content[0]
921            .table
922            .as_ref()
923            .unwrap();
924        let para = inner.table_rows[0].table_cells[0].content[0]
925            .paragraph
926            .as_ref()
927            .unwrap();
928        assert_eq!(para.text(), "deep\n");
929    }
930
931    #[test]
932    fn named_ranges_parse_as_a_map_keyed_by_name() {
933        let doc = parse(serde_json::json!({
934            "namedRanges": {
935                "intro": {
936                    "name": "intro",
937                    "namedRanges": [{
938                        "namedRangeId": "nr.1", "name": "intro",
939                        "ranges": [{"startIndex": 1, "endIndex": 20}],
940                    }],
941                },
942            },
943        }));
944        let group = doc.named_ranges.get("intro").unwrap();
945        assert_eq!(group.name, "intro");
946        assert_eq!(group.named_ranges[0].ranges[0].end_index, Some(20));
947    }
948
949    /// A `revisionId` is present only for callers with edit access; a
950    /// read-only caller sees `None`, and the write path refuses rather than
951    /// falling back to an unleased write.
952    #[test]
953    fn a_document_without_edit_access_has_no_revision_id() {
954        assert_eq!(parse(serde_json::json!({"title": "T"})).revision_id, None);
955        let with = parse(serde_json::json!({"revisionId": "rev-1"}));
956        assert_eq!(with.revision_id.as_deref(), Some("rev-1"));
957    }
958
959    #[test]
960    fn document_tolerates_unmodelled_fields() {
961        let doc = parse(serde_json::json!({
962            "documentId": "d1",
963            "title": "T",
964            "documentStyle": {"marginTop": {"magnitude": 72.0}},
965            "lists": {"kix.l1": {"listProperties": {}}},
966            "inlineObjects": {"kix.i1": {}},
967            "suggestionsViewMode": "PREVIEW_SUGGESTIONS_ACCEPTED",
968            "body": {"content": [{
969                "endIndex": 5,
970                "paragraph": {
971                    "elements": [{"textRun": {
972                        "content": "hi\n",
973                        "textStyle": {"bold": true},
974                        "suggestedInsertionIds": ["s1"],
975                    }}],
976                    "paragraphStyle": {"namedStyleType": "NORMAL_TEXT", "direction": "LEFT_TO_RIGHT"},
977                },
978            }]},
979        }));
980        assert_eq!(doc.document_id.as_deref(), Some("d1"));
981        let para = doc.body.as_ref().unwrap().content[0]
982            .paragraph
983            .as_ref()
984            .unwrap();
985        assert_eq!(para.text(), "hi\n");
986        assert_eq!(para.named_style_type(), Some("NORMAL_TEXT"));
987    }
988
989    #[test]
990    fn title_defaults_to_empty() {
991        assert_eq!(parse(serde_json::json!({})).title(), "");
992        assert_eq!(parse(serde_json::json!({"title": "T"})).title(), "T");
993    }
994}