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 only the anchor resolver computes one — see
23//!   `crate::drive::docs::anchor`.
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    /// Named ranges belonging to this tab, including legacy responses.
136    pub named_ranges: &'a HashMap<String, NamedRanges>,
137}
138
139impl<'a> ResolvedTab<'a> {
140    /// The tab's page headers, resolved and sorted by `segment_id` — map
141    /// iteration order is not deterministic.
142    #[must_use]
143    pub fn headers(&self) -> Vec<ResolvedSegment<'a>> {
144        self.headers_map
145            .map_or_else(Vec::new, |m| resolve_segments(m, |h| h.content.as_slice()))
146    }
147    /// The tab's page footers, likewise resolved and sorted.
148    #[must_use]
149    pub fn footers(&self) -> Vec<ResolvedSegment<'a>> {
150        self.footers_map
151            .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
152    }
153    /// The tab's footnotes, likewise resolved and sorted.
154    #[must_use]
155    pub fn footnotes(&self) -> Vec<ResolvedSegment<'a>> {
156        self.footnotes_map
157            .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
158    }
159}
160
161/// Resolves one segment map into sorted [`ResolvedSegment`]s.
162fn resolve_segments<'a, T>(
163    map: &'a HashMap<String, T>,
164    content: impl Fn(&'a T) -> &'a [StructuralElement],
165) -> Vec<ResolvedSegment<'a>> {
166    let mut out: Vec<ResolvedSegment<'a>> = map
167        .iter()
168        .map(|(id, seg)| ResolvedSegment {
169            segment_id: id.as_str(),
170            content: content(seg),
171        })
172        .collect();
173    out.sort_by_key(|s| s.segment_id);
174    out
175}
176
177impl Document {
178    /// The document title, or `""` when absent.
179    #[must_use]
180    pub fn title(&self) -> &str {
181        self.title.as_deref().unwrap_or("")
182    }
183
184    /// Every tab's content, depth-first through `childTabs`, falling back to
185    /// the legacy top-level [`Self::body`] (and its sibling
186    /// `headers`/`footers`/`footnotes` maps) as a single anonymous tab.
187    ///
188    /// The fallback is what makes the `tabs`-xor-`body` split invisible to
189    /// callers. A document fetched without `includeTabsContent` yields
190    /// exactly one `ResolvedTab` whose `tab_id` and `title` are `None` —
191    /// unless the document has neither body content nor any segment, in
192    /// which case there is nothing to synthesise a tab for.
193    #[must_use]
194    pub fn resolved_tabs(&self) -> Vec<ResolvedTab<'_>> {
195        if self.tabs.is_empty() {
196            if self.body.is_none()
197                && self.headers.is_empty()
198                && self.footers.is_empty()
199                && self.footnotes.is_empty()
200            {
201                return Vec::new();
202            }
203            return vec![ResolvedTab {
204                tab_id: None,
205                title: None,
206                nesting_level: 0,
207                body: self.body.as_ref(),
208                headers_map: Some(&self.headers),
209                footers_map: Some(&self.footers),
210                footnotes_map: Some(&self.footnotes),
211                named_ranges: &self.named_ranges,
212            }];
213        }
214        let mut out = Vec::new();
215        for tab in &self.tabs {
216            push_tab(tab, 0, &mut out);
217        }
218        out
219    }
220}
221
222/// Walks one tab and its `childTabs` depth-first, parent before children.
223fn push_tab<'a>(tab: &'a Tab, depth: i64, out: &mut Vec<ResolvedTab<'a>>) {
224    let props = tab.tab_properties.as_ref();
225    let doc_tab = tab.document_tab.as_ref();
226    out.push(ResolvedTab {
227        tab_id: props.and_then(|p| p.tab_id.as_deref()),
228        title: props.and_then(|p| p.title.as_deref()),
229        // Prefer the server's own nesting level when it sent one; fall back
230        // to the walk depth, which agrees with it for every well-formed
231        // response and keeps a truncated one self-consistent.
232        nesting_level: props.and_then(|p| p.nesting_level).unwrap_or(depth),
233        body: doc_tab.and_then(|dt| dt.body.as_ref()),
234        headers_map: doc_tab.map(|dt| &dt.headers),
235        footers_map: doc_tab.map(|dt| &dt.footers),
236        footnotes_map: doc_tab.map(|dt| &dt.footnotes),
237        named_ranges: match doc_tab {
238            Some(dt) => &dt.named_ranges,
239            None => empty_named_ranges(),
240        },
241    });
242    for child in &tab.child_tabs {
243        push_tab(child, depth + 1, out);
244    }
245}
246
247fn empty_named_ranges() -> &'static HashMap<String, NamedRanges> {
248    static EMPTY: std::sync::LazyLock<HashMap<String, NamedRanges>> =
249        std::sync::LazyLock::new(HashMap::new);
250    &EMPTY
251}
252
253/// One tab of a document.
254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
255pub struct Tab {
256    /// This tab's identity and position.
257    #[serde(
258        default,
259        skip_serializing_if = "Option::is_none",
260        rename = "tabProperties"
261    )]
262    pub tab_properties: Option<TabProperties>,
263    /// The tab's content, when it is a document tab.
264    #[serde(
265        default,
266        skip_serializing_if = "Option::is_none",
267        rename = "documentTab"
268    )]
269    pub document_tab: Option<DocumentTab>,
270    /// Nested tabs, recursively.
271    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "childTabs")]
272    pub child_tabs: Vec<Self>,
273}
274
275/// A tab's identity and position within the tab tree.
276#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
277pub struct TabProperties {
278    /// The tab's id — a required component of every Docs edit address in a
279    /// multi-tab document.
280    #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
281    pub tab_id: Option<String>,
282    /// The tab's display title.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub title: Option<String>,
285    /// Zero-based position among its siblings.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub index: Option<i64>,
288    /// Depth in the tab tree; `0` for a top-level tab.
289    #[serde(
290        default,
291        skip_serializing_if = "Option::is_none",
292        rename = "nestingLevel"
293    )]
294    pub nesting_level: Option<i64>,
295    /// The parent tab's id, absent for a top-level tab.
296    #[serde(
297        default,
298        skip_serializing_if = "Option::is_none",
299        rename = "parentTabId"
300    )]
301    pub parent_tab_id: Option<String>,
302}
303
304/// The document-flavoured content of a tab.
305#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
306pub struct DocumentTab {
307    /// The tab's body.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub body: Option<Body>,
310    /// Named ranges scoped to this tab.
311    #[serde(
312        default,
313        skip_serializing_if = "HashMap::is_empty",
314        rename = "namedRanges"
315    )]
316    pub named_ranges: HashMap<String, NamedRanges>,
317    /// Page headers, keyed by `segmentId`.
318    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
319    pub headers: HashMap<String, Header>,
320    /// Page footers, keyed by `segmentId`.
321    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
322    pub footers: HashMap<String, Footer>,
323    /// Footnotes, keyed by `segmentId`.
324    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
325    pub footnotes: HashMap<String, Footnote>,
326}
327
328/// A document body — the main index segment.
329///
330/// Headers, footers and footnotes live in their own segments, addressed by
331/// `segmentId`. They are modelled as sibling maps on [`Document`] and
332/// [`DocumentTab`] ([`Header`], [`Footer`], [`Footnote`]) rather than folded
333/// into this type, and normalised onto [`ResolvedTab::headers`],
334/// `.footers` and `.footnotes` by [`Document::resolved_tabs`] the same way
335/// `body` is normalised here.
336#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
337pub struct Body {
338    /// The body's structural elements, in index order.
339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
340    pub content: Vec<StructuralElement>,
341}
342
343/// One page header — a segment addressed by the map key it is stored under.
344#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
345pub struct Header {
346    /// The header's structural elements, in index order.
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub content: Vec<StructuralElement>,
349}
350
351/// One page footer — same shape as [`Header`]; Google models these as
352/// distinct message types even though the field is identical, so this
353/// mirrors that instead of aliasing.
354#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
355pub struct Footer {
356    /// The footer's structural elements, in index order.
357    #[serde(default, skip_serializing_if = "Vec::is_empty")]
358    pub content: Vec<StructuralElement>,
359}
360
361/// One footnote body — same shape again. The footnote's own id is the map
362/// key it is stored under, not a field on this struct.
363#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
364pub struct Footnote {
365    /// The footnote's structural elements, in index order.
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub content: Vec<StructuralElement>,
368}
369
370/// One structural element: exactly one of the variant fields is present, and
371/// which one is the discriminator.
372#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
373pub struct StructuralElement {
374    /// Inclusive start, in UTF-16 code units. Absent means `0`.
375    #[serde(
376        default,
377        skip_serializing_if = "Option::is_none",
378        rename = "startIndex"
379    )]
380    pub start_index: Option<i64>,
381    /// Exclusive end, in UTF-16 code units.
382    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
383    pub end_index: Option<i64>,
384    /// A paragraph.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub paragraph: Option<Paragraph>,
387    /// A table.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub table: Option<Table>,
390    /// A table of contents.
391    #[serde(
392        default,
393        skip_serializing_if = "Option::is_none",
394        rename = "tableOfContents"
395    )]
396    pub table_of_contents: Option<TableOfContents>,
397    /// A section break. Carries nothing this crate reads, so it is kept as
398    /// raw JSON purely as a present/absent discriminator.
399    #[serde(
400        default,
401        skip_serializing_if = "Option::is_none",
402        rename = "sectionBreak"
403    )]
404    pub section_break: Option<serde_json::Value>,
405}
406
407impl StructuralElement {
408    /// Inclusive start index, treating an absent field as `0`.
409    #[must_use]
410    pub fn start_index(&self) -> i64 {
411        self.start_index.unwrap_or(0)
412    }
413
414    /// Exclusive end index, treating an absent field as `0`.
415    #[must_use]
416    pub fn end_index(&self) -> i64 {
417        self.end_index.unwrap_or(0)
418    }
419}
420
421/// A paragraph and its inline elements.
422#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
423pub struct Paragraph {
424    /// The paragraph's inline elements, in index order.
425    #[serde(default, skip_serializing_if = "Vec::is_empty")]
426    pub elements: Vec<ParagraphElement>,
427    /// The paragraph's style, notably its `namedStyleType`.
428    #[serde(
429        default,
430        skip_serializing_if = "Option::is_none",
431        rename = "paragraphStyle"
432    )]
433    pub paragraph_style: Option<ParagraphStyle>,
434    /// Present when the paragraph is a list item.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub bullet: Option<Bullet>,
437}
438
439impl Paragraph {
440    /// The paragraph's text, concatenating every `textRun` in order.
441    ///
442    /// Concatenation is not optional: a paragraph with one bolded word is
443    /// three text runs, and reading only the first silently truncates it.
444    ///
445    /// The trailing `\n` every Docs paragraph carries is **kept** here —
446    /// stripping is the renderer's job, because the newline occupies one
447    /// index unit and dropping it from the text without dropping it from
448    /// `end_index` is what makes the two disagree.
449    #[must_use]
450    pub fn text(&self) -> String {
451        self.elements
452            .iter()
453            .filter_map(|el| el.text_run.as_ref())
454            .map(|run| run.content.as_str())
455            .collect()
456    }
457
458    /// The paragraph's `namedStyleType` (`HEADING_1`, `NORMAL_TEXT`, …).
459    #[must_use]
460    pub fn named_style_type(&self) -> Option<&str> {
461        self.paragraph_style
462            .as_ref()
463            .and_then(|style| style.named_style_type.as_deref())
464    }
465}
466
467/// A paragraph's style.
468#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
469pub struct ParagraphStyle {
470    /// `HEADING_1`, `TITLE`, `NORMAL_TEXT`, …
471    #[serde(
472        default,
473        skip_serializing_if = "Option::is_none",
474        rename = "namedStyleType"
475    )]
476    pub named_style_type: Option<String>,
477    /// The heading's id, when this paragraph is a heading.
478    #[serde(default, skip_serializing_if = "Option::is_none", rename = "headingId")]
479    pub heading_id: Option<String>,
480}
481
482/// A list-item marker.
483#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
484pub struct Bullet {
485    /// The list this item belongs to.
486    #[serde(default, skip_serializing_if = "Option::is_none", rename = "listId")]
487    pub list_id: Option<String>,
488    /// Nesting depth within that list.
489    #[serde(
490        default,
491        skip_serializing_if = "Option::is_none",
492        rename = "nestingLevel"
493    )]
494    pub nesting_level: Option<i64>,
495}
496
497/// One inline element of a paragraph.
498///
499/// Only `textRun` carries text this crate reads. The rest are modelled as
500/// present/absent so an element that consumes index space without
501/// contributing text — an inline image, a page break — is not mistaken for
502/// nothing at all.
503#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
504pub struct ParagraphElement {
505    /// Inclusive start, in UTF-16 code units. Absent means `0`.
506    #[serde(
507        default,
508        skip_serializing_if = "Option::is_none",
509        rename = "startIndex"
510    )]
511    pub start_index: Option<i64>,
512    /// Exclusive end, in UTF-16 code units.
513    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
514    pub end_index: Option<i64>,
515    /// A run of text with uniform styling.
516    #[serde(default, skip_serializing_if = "Option::is_none", rename = "textRun")]
517    pub text_run: Option<TextRun>,
518    /// An inline image or drawing. Consumes one index unit, contributes no
519    /// text — so its paragraph's text is shorter than its index span, which
520    /// is correct and must not be "fixed".
521    #[serde(
522        default,
523        skip_serializing_if = "Option::is_none",
524        rename = "inlineObjectElement"
525    )]
526    pub inline_object_element: Option<InlineObjectElement>,
527    /// A page break.
528    #[serde(default, skip_serializing_if = "Option::is_none", rename = "pageBreak")]
529    pub page_break: Option<serde_json::Value>,
530    /// A column break.
531    #[serde(
532        default,
533        skip_serializing_if = "Option::is_none",
534        rename = "columnBreak"
535    )]
536    pub column_break: Option<serde_json::Value>,
537    /// A horizontal rule.
538    #[serde(
539        default,
540        skip_serializing_if = "Option::is_none",
541        rename = "horizontalRule"
542    )]
543    pub horizontal_rule: Option<serde_json::Value>,
544    /// A footnote reference.
545    #[serde(
546        default,
547        skip_serializing_if = "Option::is_none",
548        rename = "footnoteReference"
549    )]
550    pub footnote_reference: Option<serde_json::Value>,
551    /// Auto-text such as a page number.
552    #[serde(default, skip_serializing_if = "Option::is_none", rename = "autoText")]
553    pub auto_text: Option<serde_json::Value>,
554    /// An equation.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub equation: Option<serde_json::Value>,
557    /// A person mention.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub person: Option<serde_json::Value>,
560    /// A rich link chip.
561    #[serde(default, skip_serializing_if = "Option::is_none", rename = "richLink")]
562    pub rich_link: Option<serde_json::Value>,
563}
564
565/// A run of text with uniform styling.
566#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
567pub struct TextRun {
568    /// Pending insertions: unsafe to use as an edit anchor.
569    #[serde(
570        default,
571        skip_serializing_if = "Vec::is_empty",
572        rename = "suggestedInsertionIds"
573    )]
574    pub suggested_insertion_ids: Vec<String>,
575    /// Pending deletions: unsafe to include in an edit range.
576    #[serde(
577        default,
578        skip_serializing_if = "Vec::is_empty",
579        rename = "suggestedDeletionIds"
580    )]
581    pub suggested_deletion_ids: Vec<String>,
582    /// The run's text, including any trailing newline.
583    #[serde(default)]
584    pub content: String,
585}
586
587/// A reference to an inline object.
588#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
589pub struct InlineObjectElement {
590    /// The object's id, resolvable against the document's `inlineObjects`.
591    #[serde(
592        default,
593        skip_serializing_if = "Option::is_none",
594        rename = "inlineObjectId"
595    )]
596    pub inline_object_id: Option<String>,
597}
598
599/// A table.
600#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
601pub struct Table {
602    /// Row count.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub rows: Option<i64>,
605    /// Column count.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub columns: Option<i64>,
608    /// The table's rows.
609    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableRows")]
610    pub table_rows: Vec<TableRow>,
611    /// Whether the original table contains any pending suggestion, including
612    /// unmodelled nested fields. Captured before narrowing the read model;
613    /// no duplicate document prose is retained or emitted.
614    #[serde(skip)]
615    pub has_pending_suggestions: bool,
616}
617
618pub(super) fn has_suggestions(value: &serde_json::Value) -> bool {
619    match value {
620        serde_json::Value::Object(fields) => fields.iter().any(|(key, value)| {
621            (key.starts_with("suggested")
622                && match value {
623                    serde_json::Value::Array(v) => !v.is_empty(),
624                    serde_json::Value::Object(v) => !v.is_empty(),
625                    serde_json::Value::Null => false,
626                    _ => true,
627                })
628                || has_suggestions(value)
629        }),
630        serde_json::Value::Array(values) => values.iter().any(has_suggestions),
631        _ => false,
632    }
633}
634
635impl<'de> Deserialize<'de> for Table {
636    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
637        #[derive(Deserialize)]
638        struct WireTable {
639            #[serde(default)]
640            rows: Option<i64>,
641            #[serde(default)]
642            columns: Option<i64>,
643            #[serde(default, rename = "tableRows")]
644            table_rows: Vec<TableRow>,
645        }
646        let raw = serde_json::Value::deserialize(deserializer)?;
647        let has_pending_suggestions = has_suggestions(&raw);
648        let wire: WireTable = serde_json::from_value(raw).map_err(serde::de::Error::custom)?;
649        Ok(Self {
650            rows: wire.rows,
651            columns: wire.columns,
652            table_rows: wire.table_rows,
653            has_pending_suggestions,
654        })
655    }
656}
657
658/// One row of a table.
659#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
660pub struct TableRow {
661    /// Inclusive start, in UTF-16 code units.
662    #[serde(
663        default,
664        skip_serializing_if = "Option::is_none",
665        rename = "startIndex"
666    )]
667    pub start_index: Option<i64>,
668    /// Exclusive end, in UTF-16 code units.
669    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
670    pub end_index: Option<i64>,
671    /// The row's cells.
672    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableCells")]
673    pub table_cells: Vec<TableCell>,
674}
675
676/// One cell of a table row.
677#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
678pub struct TableCell {
679    /// Inclusive start, in UTF-16 code units.
680    #[serde(
681        default,
682        skip_serializing_if = "Option::is_none",
683        rename = "startIndex"
684    )]
685    pub start_index: Option<i64>,
686    /// Exclusive end, in UTF-16 code units.
687    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
688    pub end_index: Option<i64>,
689    /// The cell's own structural elements — where the recursion lives, since
690    /// a cell may contain another table.
691    #[serde(default, skip_serializing_if = "Vec::is_empty")]
692    pub content: Vec<StructuralElement>,
693    /// Read-only spans used to reject merged-cell structural edits.
694    #[serde(
695        default,
696        skip_serializing_if = "Option::is_none",
697        rename = "tableCellStyle"
698    )]
699    pub table_cell_style: Option<TableCellStyle>,
700}
701
702/// Read-only cell spans; other formatting remains outside the edit model.
703#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
704pub struct TableCellStyle {
705    /// Number of rows spanned by this cell.
706    #[serde(default, skip_serializing_if = "Option::is_none", rename = "rowSpan")]
707    pub row_span: Option<i64>,
708    /// Number of columns spanned by this cell.
709    #[serde(
710        default,
711        skip_serializing_if = "Option::is_none",
712        rename = "columnSpan"
713    )]
714    pub column_span: Option<i64>,
715}
716
717/// A table of contents.
718#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
719pub struct TableOfContents {
720    /// The generated entries, as structural elements.
721    #[serde(default, skip_serializing_if = "Vec::is_empty")]
722    pub content: Vec<StructuralElement>,
723}
724
725/// Every named range sharing one name.
726#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
727pub struct NamedRanges {
728    /// The shared name.
729    #[serde(default)]
730    pub name: String,
731    /// The ranges carrying it.
732    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "namedRanges")]
733    pub named_ranges: Vec<NamedRange>,
734}
735
736/// One named range.
737#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
738pub struct NamedRange {
739    /// The range's id.
740    #[serde(
741        default,
742        skip_serializing_if = "Option::is_none",
743        rename = "namedRangeId"
744    )]
745    pub named_range_id: Option<String>,
746    /// The range's name.
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub name: Option<String>,
749    /// The index spans it covers.
750    #[serde(default, skip_serializing_if = "Vec::is_empty")]
751    pub ranges: Vec<Range>,
752}
753
754/// An index span within one segment of one tab.
755#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
756pub struct Range {
757    /// The segment; empty/absent means the document body.
758    #[serde(default, skip_serializing_if = "Option::is_none", rename = "segmentId")]
759    pub segment_id: Option<String>,
760    /// The tab; empty/absent means the first tab.
761    #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
762    pub tab_id: Option<String>,
763    /// Inclusive start, in UTF-16 code units.
764    #[serde(
765        default,
766        skip_serializing_if = "Option::is_none",
767        rename = "startIndex"
768    )]
769    pub start_index: Option<i64>,
770    /// Exclusive end, in UTF-16 code units.
771    #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
772    pub end_index: Option<i64>,
773}
774
775#[cfg(test)]
776#[allow(clippy::unwrap_used, clippy::expect_used)]
777mod tests {
778    use super::*;
779
780    fn parse(json: serde_json::Value) -> Document {
781        serde_json::from_value(json).unwrap()
782    }
783
784    /// The single highest-value test in this file. Docs serialises proto3
785    /// JSON, which omits zero-valued integers, so the first element of every
786    /// real document has no `startIndex` key. Reading it as anything but `0`
787    /// shifts every reported index.
788    #[test]
789    fn a_missing_start_index_means_zero() {
790        let doc = parse(serde_json::json!({
791            "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
792        }));
793        let el = &doc.body.as_ref().unwrap().content[0];
794        assert_eq!(el.start_index, None, "the key really is absent");
795        assert_eq!(el.start_index(), 0, "and the accessor reads it as zero");
796        assert_eq!(el.end_index(), 1);
797    }
798
799    #[test]
800    fn a_present_but_empty_section_break_is_still_recognised() {
801        let doc = parse(serde_json::json!({
802            "body": {"content": [{"sectionBreak": {}}]},
803        }));
804        let el = &doc.body.as_ref().unwrap().content[0];
805        assert!(el.section_break.is_some(), "present-and-empty is Some");
806        assert!(el.paragraph.is_none());
807    }
808
809    #[test]
810    fn an_absent_section_break_is_none() {
811        let doc = parse(serde_json::json!({
812            "body": {"content": [{"paragraph": {"elements": []}}]},
813        }));
814        assert!(doc.body.as_ref().unwrap().content[0]
815            .section_break
816            .is_none());
817    }
818
819    #[test]
820    fn text_run_content_defaults_to_empty() {
821        let run: TextRun = serde_json::from_value(serde_json::json!({})).unwrap();
822        assert_eq!(run.content, "");
823    }
824
825    /// A paragraph with a bolded word is several text runs. Reading only the
826    /// first silently truncates it.
827    #[test]
828    fn paragraph_text_joins_every_text_run_in_order() {
829        let para: Paragraph = serde_json::from_value(serde_json::json!({
830            "elements": [
831                {"textRun": {"content": "Hello "}},
832                {"textRun": {"content": "bold"}},
833                {"textRun": {"content": " world\n"}},
834            ],
835        }))
836        .unwrap();
837        assert_eq!(para.text(), "Hello bold world\n");
838    }
839
840    /// An inline image consumes index space but contributes no text, so a
841    /// paragraph's text is shorter than its index span. That is correct.
842    #[test]
843    fn paragraph_text_skips_a_non_text_element() {
844        let para: Paragraph = serde_json::from_value(serde_json::json!({
845            "elements": [
846                {"textRun": {"content": "a"}},
847                {"inlineObjectElement": {"inlineObjectId": "kix.1"}},
848                {"textRun": {"content": "b\n"}},
849            ],
850        }))
851        .unwrap();
852        assert_eq!(para.text(), "ab\n");
853        assert!(para.elements[1].inline_object_element.is_some());
854    }
855
856    #[test]
857    fn resolved_tabs_synthesises_one_anonymous_tab_from_a_legacy_body() {
858        let doc = parse(serde_json::json!({
859            "title": "Legacy",
860            "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
861        }));
862        let tabs = doc.resolved_tabs();
863        assert_eq!(tabs.len(), 1);
864        assert_eq!(tabs[0].tab_id, None);
865        assert_eq!(tabs[0].title, None);
866        assert_eq!(tabs[0].nesting_level, 0);
867        assert!(tabs[0].body.is_some());
868    }
869
870    #[test]
871    fn resolved_tabs_of_a_document_with_neither_body_nor_tabs_is_empty() {
872        assert!(parse(serde_json::json!({"title": "Empty"}))
873            .resolved_tabs()
874            .is_empty());
875    }
876
877    /// The legacy single-body shape carries its segments at the top level.
878    #[test]
879    fn resolved_tabs_resolves_top_level_segments_for_a_legacy_body() {
880        let doc = parse(serde_json::json!({
881            "body": {"content": []},
882            "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
883            "footers": {"f1": {"content": []}},
884            "footnotes": {"fn1": {"content": []}},
885        }));
886        let tabs = doc.resolved_tabs();
887        assert_eq!(tabs[0].headers().len(), 1);
888        assert_eq!(tabs[0].headers()[0].segment_id, "h1");
889        assert_eq!(tabs[0].footers().len(), 1);
890        assert_eq!(tabs[0].footnotes().len(), 1);
891    }
892
893    /// A tab with none of these maps yields empty `Vec`s, not an error.
894    #[test]
895    fn resolved_tabs_has_empty_segments_when_the_document_has_none() {
896        let doc = parse(serde_json::json!({"body": {"content": []}}));
897        let tabs = doc.resolved_tabs();
898        assert!(tabs[0].headers().is_empty());
899        assert!(tabs[0].footers().is_empty());
900        assert!(tabs[0].footnotes().is_empty());
901    }
902
903    /// A document with no `tabs` and no `body` but a populated segment map
904    /// still yields a tab to hang that segment off, rather than silently
905    /// discarding fetched content because the legacy fallback keyed only on
906    /// `body`'s presence.
907    #[test]
908    fn resolved_tabs_synthesises_a_tab_for_segments_with_no_body() {
909        let doc = parse(serde_json::json!({
910            "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
911        }));
912        let tabs = doc.resolved_tabs();
913        assert_eq!(tabs.len(), 1);
914        assert!(tabs[0].body.is_none());
915        assert_eq!(tabs[0].headers().len(), 1);
916    }
917
918    /// Segments are tab-scoped in the `tabs` shape, and resolved sorted by
919    /// `segment_id` since map iteration order is not guaranteed.
920    #[test]
921    fn resolved_tabs_resolves_tab_scoped_segments_sorted_by_segment_id() {
922        let doc = parse(serde_json::json!({
923            "tabs": [{
924                "tabProperties": {"tabId": "t.0"},
925                "documentTab": {
926                    "body": {"content": []},
927                    "headers": {
928                        "h2": {"content": []},
929                        "h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
930                    },
931                },
932            }],
933        }));
934        let tabs = doc.resolved_tabs();
935        let headers = tabs[0].headers();
936        let ids: Vec<_> = headers.iter().map(|s| s.segment_id).collect();
937        assert_eq!(ids, vec!["h1", "h2"]);
938        assert_eq!(headers[0].content.len(), 1);
939    }
940
941    #[test]
942    fn resolved_tabs_walks_child_tabs_depth_first_parent_before_children() {
943        let doc = parse(serde_json::json!({
944            "tabs": [
945                {
946                    "tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
947                    "documentTab": {"body": {"content": []}},
948                    "childTabs": [{
949                        "tabProperties": {"tabId": "t.0.a", "title": "One A", "nestingLevel": 1},
950                        "documentTab": {"body": {"content": []}},
951                    }],
952                },
953                {
954                    "tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
955                    "documentTab": {"body": {"content": []}},
956                },
957            ],
958        }));
959        let ids: Vec<_> = doc.resolved_tabs().iter().map(|t| t.tab_id).collect();
960        assert_eq!(ids, vec![Some("t.0"), Some("t.0.a"), Some("t.1")]);
961        assert_eq!(doc.resolved_tabs()[1].nesting_level, 1);
962    }
963
964    /// A response that omits `nestingLevel` still gets a self-consistent
965    /// depth from the walk, rather than collapsing every tab to level 0.
966    #[test]
967    fn resolved_tabs_falls_back_to_walk_depth_when_nesting_level_is_absent() {
968        let doc = parse(serde_json::json!({
969            "tabs": [{
970                "tabProperties": {"tabId": "t.0"},
971                "childTabs": [{"tabProperties": {"tabId": "t.0.a"}}],
972            }],
973        }));
974        let tabs = doc.resolved_tabs();
975        assert_eq!(tabs[0].nesting_level, 0);
976        assert_eq!(tabs[1].nesting_level, 1);
977    }
978
979    /// `tabs` wins over `body`; they never both contribute.
980    #[test]
981    fn tabs_take_precedence_over_a_legacy_body() {
982        let doc = parse(serde_json::json!({
983            "body": {"content": [{"paragraph": {"elements": [{"textRun": {"content": "legacy\n"}}]}}]},
984            "tabs": [{
985                "tabProperties": {"tabId": "t.0"},
986                "documentTab": {"body": {"content": []}},
987            }],
988        }));
989        let tabs = doc.resolved_tabs();
990        assert_eq!(tabs.len(), 1);
991        assert_eq!(tabs[0].tab_id, Some("t.0"));
992    }
993
994    #[test]
995    fn nested_tables_parse_recursively() {
996        let doc = parse(serde_json::json!({
997            "body": {"content": [{
998                "startIndex": 1, "endIndex": 40,
999                "table": {
1000                    "rows": 1, "columns": 1,
1001                    "tableRows": [{"tableCells": [{"content": [{
1002                        "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
1003                            "content": [{"paragraph": {"elements": [
1004                                {"textRun": {"content": "deep\n"}}]}}],
1005                        }]}]},
1006                    }]}]}],
1007                },
1008            }]},
1009        }));
1010        let outer = doc.body.as_ref().unwrap().content[0]
1011            .table
1012            .as_ref()
1013            .unwrap();
1014        let inner = outer.table_rows[0].table_cells[0].content[0]
1015            .table
1016            .as_ref()
1017            .unwrap();
1018        let para = inner.table_rows[0].table_cells[0].content[0]
1019            .paragraph
1020            .as_ref()
1021            .unwrap();
1022        assert_eq!(para.text(), "deep\n");
1023    }
1024
1025    #[test]
1026    fn named_ranges_parse_as_a_map_keyed_by_name() {
1027        let doc = parse(serde_json::json!({
1028            "namedRanges": {
1029                "intro": {
1030                    "name": "intro",
1031                    "namedRanges": [{
1032                        "namedRangeId": "nr.1", "name": "intro",
1033                        "ranges": [{"startIndex": 1, "endIndex": 20}],
1034                    }],
1035                },
1036            },
1037        }));
1038        let group = doc.named_ranges.get("intro").unwrap();
1039        assert_eq!(group.name, "intro");
1040        assert_eq!(group.named_ranges[0].ranges[0].end_index, Some(20));
1041    }
1042
1043    /// A `revisionId` is present only for callers with edit access; a
1044    /// read-only caller sees `None`, and the write path refuses rather than
1045    /// falling back to an unleased write.
1046    #[test]
1047    fn a_document_without_edit_access_has_no_revision_id() {
1048        assert_eq!(parse(serde_json::json!({"title": "T"})).revision_id, None);
1049        let with = parse(serde_json::json!({"revisionId": "rev-1"}));
1050        assert_eq!(with.revision_id.as_deref(), Some("rev-1"));
1051    }
1052
1053    #[test]
1054    fn document_tolerates_unmodelled_fields() {
1055        let doc = parse(serde_json::json!({
1056            "documentId": "d1",
1057            "title": "T",
1058            "documentStyle": {"marginTop": {"magnitude": 72.0}},
1059            "lists": {"kix.l1": {"listProperties": {}}},
1060            "inlineObjects": {"kix.i1": {}},
1061            "suggestionsViewMode": "PREVIEW_SUGGESTIONS_ACCEPTED",
1062            "body": {"content": [{
1063                "endIndex": 5,
1064                "paragraph": {
1065                    "elements": [{"textRun": {
1066                        "content": "hi\n",
1067                        "textStyle": {"bold": true},
1068                        "suggestedInsertionIds": ["s1"],
1069                    }}],
1070                    "paragraphStyle": {"namedStyleType": "NORMAL_TEXT", "direction": "LEFT_TO_RIGHT"},
1071                },
1072            }]},
1073        }));
1074        assert_eq!(doc.document_id.as_deref(), Some("d1"));
1075        let para = doc.body.as_ref().unwrap().content[0]
1076            .paragraph
1077            .as_ref()
1078            .unwrap();
1079        assert_eq!(para.text(), "hi\n");
1080        assert_eq!(para.named_style_type(), Some("NORMAL_TEXT"));
1081    }
1082
1083    /// A `suggested*` key counts only when it carries something: an empty
1084    /// list or map, or an explicit null, is Docs' way of saying "none".
1085    /// Any scalar value there is unexpected, so it fails closed as a
1086    /// suggestion; keys without the prefix never count.
1087    #[test]
1088    fn has_suggestions_counts_only_non_empty_suggested_fields_at_any_depth() {
1089        for (value, expected) in [
1090            (serde_json::json!({"suggestedInsertionIds": ["s"]}), true),
1091            (
1092                serde_json::json!({"suggestedTextStyleChanges": {"s": {}}}),
1093                true,
1094            ),
1095            (serde_json::json!({"suggestedBold": true}), true),
1096            (serde_json::json!({"suggestedNote": "text"}), true),
1097            (serde_json::json!({"suggestedCount": 0}), true),
1098            (serde_json::json!({"suggestedInsertionIds": []}), false),
1099            (serde_json::json!({"suggestedTextStyleChanges": {}}), false),
1100            (serde_json::json!({"suggestedNote": null}), false),
1101            (serde_json::json!({"insertionIds": ["s"]}), false),
1102            (serde_json::json!({"notSuggested": true}), false),
1103            (
1104                serde_json::json!({"a": [{"b": {"suggestedDeletionIds": ["s"]}}]}),
1105                true,
1106            ),
1107            (
1108                serde_json::json!({"a": [{"b": {"suggestedNote": null}}]}),
1109                false,
1110            ),
1111            (serde_json::json!("suggestedInsertionIds"), false),
1112            (serde_json::json!(null), false),
1113        ] {
1114            assert_eq!(has_suggestions(&value), expected, "{value}");
1115        }
1116    }
1117
1118    /// The flag is captured from the raw table, including fields the read
1119    /// model does not carry, and never leaks back onto the wire.
1120    #[test]
1121    fn a_table_remembers_unmodelled_suggestions_without_serialising_them() {
1122        let clean: Table = serde_json::from_value(serde_json::json!({
1123            "rows": 1,
1124            "columns": 1,
1125            "tableRows": [{"suggestedInsertionIds": []}],
1126        }))
1127        .unwrap();
1128        assert!(!clean.has_pending_suggestions);
1129        let suggested: Table = serde_json::from_value(serde_json::json!({
1130            "rows": 1,
1131            "columns": 1,
1132            "tableRows": [{"tableCells": [{"tableCellStyle": {"suggestedTableCellStyleChanges": {"s": {}}}}]}],
1133        }))
1134        .unwrap();
1135        assert!(suggested.has_pending_suggestions);
1136        let wire = serde_json::to_value(&suggested).unwrap();
1137        assert!(!wire.to_string().contains("suggested"), "{wire}");
1138        assert!(wire.get("has_pending_suggestions").is_none());
1139    }
1140
1141    #[test]
1142    fn title_defaults_to_empty() {
1143        assert_eq!(parse(serde_json::json!({})).title(), "");
1144        assert_eq!(parse(serde_json::json!({"title": "T"})).title(), "T");
1145    }
1146}