Skip to main content

org_gdocs/
project.rs

1//! P3 — projecting an org document into Google Docs `batchUpdate` requests.
2//!
3//! This module is a **pure, total** transform (engineering invariant EI-4): a
4//! [`kb::ast::Document`] becomes an ordered [`Vec`] of the generated typed
5//! [`google_docs1::api::Request`] structs plus a [`PositionMap`] recording where
6//! each anchorable element begins. It performs no IO, never panics, and produces
7//! identical output for identical input.
8//!
9//! Two invariants shape the design:
10//!
11//! - **DI-7 (UTF-16 indexing).** Every index is a UTF-16 code-unit offset computed
12//!   through [`crate::google::utf16::len_utf16`] — never a byte or `char` count.
13//! - **Typed requests only.** Requests are built from the generated structs, never
14//!   from `serde_json::json!` literals, so a malformed request fails to compile.
15//!
16//! The projection assumes the cursor starts at index 1 — the first writable
17//! position of a freshly created (or freshly cleared) Google Doc body. Creating
18//! the doc, clearing an existing one, and issuing the batch are the caller's job
19//! (P4a/P7); this module only decides *what* to send.
20//!
21//! ## Position map and `CUSTOM_ID`s
22//!
23//! Headings key the map by their `:CUSTOM_ID:` (assigned in the body by P2,
24//! [`crate::custom_id`]); other block elements get a hierarchical
25//! `<parent>/<type>-<n>` id (DI-8). The map lets pull (Q1) resolve a Google
26//! comment's index back to the containing structural element.
27//!
28//! ## Known degradations (v1)
29//!
30//! - Table cells carry **plain text only**; inline formatting inside a cell is
31//!   dropped (cell indices shift on insert, making in-cell styling fragile).
32//! - A horizontal rule renders as a line of `─` glyphs (the Docs API has no
33//!   first-class rule insertable via `batchUpdate`).
34//! - Org table rule rows (`|---+---|`) are dropped — they are presentation, not
35//!   data, and the kb parser keeps them only for round-trip fidelity.
36//!
37//! Soft line breaks inside a paragraph (the parser's `Inline::LineBreak`, emitted
38//! only to round-trip source wrapping) project to a single space: source-line
39//! wrapping is presentation, and the sole paragraph boundary is a blank line,
40//! which already ends the block.
41
42use std::collections::BTreeMap;
43use std::fmt::Write as _;
44
45use google_docs1::api::{
46    CreateParagraphBulletsRequest, Dimension, InsertTableRequest, InsertTextRequest, Link,
47    Location, ParagraphStyle, Range, Request, TextStyle, UpdateParagraphStyleRequest,
48    UpdateTextStyleRequest, WeightedFontFamily,
49};
50use google_docs1::common::FieldMask;
51use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
52use sha2::{Digest, Sha256};
53
54use crate::custom_id::section_id;
55use crate::google::utf16::len_utf16;
56
57/// The structural kind of a projected element, stored alongside its index in the
58/// [`PositionMap`] so pull can report what a comment is anchored to.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ElementKind {
61    /// An org heading (`*`…), projected as a `HEADING_n` named style.
62    Heading,
63    /// A body paragraph.
64    Paragraph,
65    /// An ordered, unordered, or checkbox list.
66    List,
67    /// A table.
68    Table,
69    /// A `#+begin_src` block, rendered monospace.
70    SrcBlock,
71    /// A `#+begin_example` block, rendered monospace.
72    ExampleBlock,
73    /// A block quote.
74    Quote,
75    /// A horizontal rule.
76    HorizontalRule,
77}
78
79impl ElementKind {
80    /// The slug used when minting a hierarchical id for a non-heading element,
81    /// and as this kind's symbol in the sync-state s-expression ([`crate::syncstate`]).
82    #[must_use]
83    pub const fn slug(self) -> &'static str {
84        match self {
85            Self::Heading => "heading",
86            Self::Paragraph => "paragraph",
87            Self::List => "list",
88            Self::Table => "table",
89            Self::SrcBlock => "src",
90            Self::ExampleBlock => "example",
91            Self::Quote => "quote",
92            Self::HorizontalRule => "hr",
93        }
94    }
95
96    /// The kind for a [`slug`](Self::slug), or `None` if unrecognized.
97    #[must_use]
98    pub fn from_slug(slug: &str) -> Option<Self> {
99        match slug {
100            "heading" => Some(Self::Heading),
101            "paragraph" => Some(Self::Paragraph),
102            "list" => Some(Self::List),
103            "table" => Some(Self::Table),
104            "src" => Some(Self::SrcBlock),
105            "example" => Some(Self::ExampleBlock),
106            "quote" => Some(Self::Quote),
107            "hr" => Some(Self::HorizontalRule),
108            _ => None,
109        }
110    }
111}
112
113/// Where an anchorable element begins in the projected document.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Position {
116    /// The element's first UTF-16 code-unit index in the document body.
117    pub index: u32,
118    /// The element's structural kind.
119    pub kind: ElementKind,
120}
121
122/// Map from `CUSTOM_ID` to the projected [`Position`] of that element.
123///
124/// Keyed by id (a [`BTreeMap`]) so emission is deterministic; pull (Q1) scans the
125/// values to find the largest index at or below a comment's anchor.
126pub type PositionMap = BTreeMap<String, Position>;
127
128/// The output of [`project`]: the ordered batch requests and the position map.
129///
130/// [`google_docs1::api::Request`] does not implement `PartialEq`; compare two
131/// projections by serializing `requests` rather than via `==`.
132#[derive(Debug, Clone)]
133pub struct Projection {
134    /// The `batchUpdate` requests, in application order.
135    pub requests: Vec<Request>,
136    /// Where each anchorable element landed.
137    pub positions: PositionMap,
138}
139
140impl Projection {
141    /// A stable fingerprint of the projected requests (a hex SHA-256 of their
142    /// serialized form).
143    ///
144    /// Push records this in `** Sync State`; a re-push whose projection produces
145    /// the same fingerprint can skip the full-replace and so preserve the anchors
146    /// of existing Google comments (a full-replace deletes the text they anchor
147    /// to). Identical input AST ⇒ identical requests ⇒ identical fingerprint.
148    #[must_use]
149    pub fn fingerprint(&self) -> String {
150        // Request serialization is deterministic (struct field order); a Vec writer
151        // does not perform IO, so this does not fail in practice — an empty digest
152        // input on the impossible error path still yields a stable value.
153        let serialized = serde_json::to_vec(&self.requests).unwrap_or_default();
154        let mut hasher = Sha256::new();
155        hasher.update(&serialized);
156        hasher
157            .finalize()
158            .iter()
159            .fold(String::new(), |mut acc, byte| {
160                // Writing a byte as two hex digits into a `String` cannot fail.
161                let _ = write!(acc, "{byte:02x}");
162                acc
163            })
164    }
165}
166
167/// Parent id used for elements that precede the first heading.
168///
169/// Exposed to [`crate::comments`] (Q1) so anchor resolution can recognize a
170/// document-level (pre-heading) position and report it as such rather than as a
171/// spurious section.
172pub(crate) const DOCUMENT_PARENT: &str = "doc";
173
174/// Font family used for inline code, verbatim spans, and source/example blocks.
175const MONOSPACE_FONT: &str = "Courier New";
176
177/// Glyph run a horizontal rule degrades to.
178const RULE_GLYPHS: &str = "────────────────────";
179
180/// Checkbox markers prefixed to list items so checkbox state survives projection.
181const MARK_UNCHECKED: &str = "\u{2610} ";
182const MARK_CHECKED: &str = "\u{2611} ";
183
184/// Project an org document into ordered Docs requests and a position map.
185#[must_use]
186pub fn project(document: &Document) -> Projection {
187    let mut builder = Builder::new();
188    builder.blocks(&document.blocks, DOCUMENT_PARENT);
189    Projection {
190        requests: builder.requests,
191        positions: builder.positions,
192    }
193}
194
195/// Mutable projection state: the request list, the position map, the running
196/// UTF-16 cursor, and per-`(parent, kind)` id counters.
197struct Builder {
198    requests: Vec<Request>,
199    positions: PositionMap,
200    cursor: u32,
201    counters: BTreeMap<String, u32>,
202}
203
204impl Builder {
205    fn new() -> Self {
206        Self {
207            requests: Vec::new(),
208            positions: PositionMap::new(),
209            cursor: 1,
210            counters: BTreeMap::new(),
211        }
212    }
213
214    /// Project a sequence of sibling blocks under `parent`.
215    fn blocks(&mut self, blocks: &[Block], parent: &str) {
216        for block in blocks {
217            self.block(block, parent);
218        }
219    }
220
221    /// Project one block. Org metadata blocks (drawers, planning, keywords,
222    /// comments, blank lines) carry no projected content and are skipped.
223    fn block(&mut self, block: &Block, parent: &str) {
224        match block {
225            Block::Heading {
226                level,
227                title,
228                children,
229                ..
230            } => self.heading(*level, title, children),
231            Block::Paragraph { inlines } => self.paragraph(inlines, parent),
232            Block::SrcBlock { content, .. } => {
233                self.monospace_block(content, parent, ElementKind::SrcBlock);
234            }
235            Block::ExampleBlock { content } => {
236                self.monospace_block(content, parent, ElementKind::ExampleBlock);
237            }
238            Block::QuoteBlock { children } => self.quote(children, parent),
239            Block::List { list_type, items } => self.list(list_type, items, parent),
240            Block::Table { rows } => self.table(rows, parent),
241            Block::HorizontalRule => self.horizontal_rule(parent),
242            Block::PropertyDrawer { .. }
243            | Block::LogbookDrawer { .. }
244            | Block::Planning { .. }
245            | Block::Comment { .. }
246            | Block::Keyword { .. }
247            | Block::BlankLine => {}
248        }
249    }
250
251    /// Project a heading line, then its nested children under the heading's id.
252    fn heading(&mut self, level: u8, title: &Title, children: &[Block]) {
253        let id = heading_id(title, children);
254        let start = self.cursor;
255        self.record(&id, ElementKind::Heading, start);
256        let (range_start, range_end) = self.insert_line(title.as_str());
257        self.push(named_style_request(
258            range_start,
259            range_end,
260            &heading_style(level),
261        ));
262        self.blocks(children, &id);
263    }
264
265    /// Project a normal-text paragraph with its inline formatting.
266    fn paragraph(&mut self, inlines: &[Inline], parent: &str) {
267        let id = self.next_id(parent, ElementKind::Paragraph);
268        let start = self.cursor;
269        self.record(&id, ElementKind::Paragraph, start);
270        let (text, spans) = flatten(inlines);
271        let (range_start, range_end) = self.insert_line(&text);
272        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
273        self.styles(range_start, &spans);
274    }
275
276    /// Project a source or example block as a single monospace paragraph.
277    fn monospace_block(&mut self, content: &str, parent: &str, kind: ElementKind) {
278        let id = self.next_id(parent, kind);
279        let start = self.cursor;
280        self.record(&id, kind, start);
281        let (range_start, range_end) = self.insert_line(content);
282        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
283        self.push(text_style_request(
284            range_start,
285            range_end,
286            &Style::Monospace,
287        ));
288    }
289
290    /// Project a block quote as indented normal-text paragraphs.
291    fn quote(&mut self, children: &[Block], parent: &str) {
292        let id = self.next_id(parent, ElementKind::Quote);
293        let start = self.cursor;
294        self.record(&id, ElementKind::Quote, start);
295        for child in children {
296            if let Block::Paragraph { inlines } = child {
297                let (text, spans) = flatten(inlines);
298                let (range_start, range_end) = self.insert_line(&text);
299                self.push(indented_style_request(range_start, range_end));
300                self.styles(range_start, &spans);
301            } else {
302                self.block(child, &id);
303            }
304        }
305    }
306
307    /// Project a list: one paragraph per item, with bullets (plain lists) or a
308    /// checkbox-marker prefix (checkbox lists, so checked state is visible).
309    fn list(&mut self, list_type: &ListType, items: &[ListItem], parent: &str) {
310        let id = self.next_id(parent, ElementKind::List);
311        let start = self.cursor;
312        self.record(&id, ElementKind::List, start);
313
314        let has_checkbox = items
315            .iter()
316            .any(|item| !matches!(item.checkbox, Checkbox::NoCheckbox));
317
318        let mut joined = String::new();
319        let mut spans: Vec<Span> = Vec::new();
320        for item in items {
321            let base = len_u32(&joined);
322            let prefix = checkbox_marker(&item.checkbox);
323            joined.push_str(prefix);
324            let item_base = base + len_u32(prefix);
325            let (text, item_spans) = flatten_blocks(&item.content);
326            for span in item_spans {
327                spans.push(span.shifted(item_base));
328            }
329            joined.push_str(&text);
330            joined.push('\n');
331        }
332
333        let range_start = self.cursor;
334        let range_end = self.emit_text(joined);
335        if !has_checkbox {
336            self.push(bullets_request(range_start, range_end, list_type));
337        }
338        self.styles(range_start, &spans);
339    }
340
341    /// Project a table: an `InsertTable` skeleton followed by per-cell text
342    /// inserts. Cell indices follow the empty-table layout and are issued in
343    /// descending order so earlier (lower-index) inserts do not shift later ones.
344    fn table(&mut self, rows: &[Vec<TableCell>], parent: &str) {
345        let id = self.next_id(parent, ElementKind::Table);
346        let start = self.cursor;
347        self.record(&id, ElementKind::Table, start);
348
349        // Org table rule rows (`|---+---|`) are presentation, not data; drop them
350        // so they do not project as a bogus row. The kb parser keeps them verbatim
351        // for round-trip fidelity, so the projection is where they are removed.
352        let data_rows: Vec<&Vec<TableCell>> = rows.iter().filter(|row| !is_rule_row(row)).collect();
353
354        let row_count = len_u32_of(data_rows.len());
355        let col_count = len_u32_of(data_rows.iter().map(|row| row.len()).max().unwrap_or(0));
356        if row_count == 0 || col_count == 0 {
357            return;
358        }
359
360        self.push(insert_table_request(start, row_count, col_count));
361
362        let mut cells: Vec<(u32, String)> = Vec::new();
363        let mut text_units = 0u32;
364        for (row_index, row) in data_rows.iter().enumerate() {
365            for (col_index, cell) in row.iter().enumerate() {
366                let text = flatten(&cell.inlines).0;
367                if text.is_empty() {
368                    continue;
369                }
370                let index = cell_content_index(
371                    start,
372                    len_u32_of(row_index),
373                    len_u32_of(col_index),
374                    col_count,
375                );
376                text_units = text_units.saturating_add(len_u32(&text));
377                cells.push((index, text));
378            }
379        }
380        cells.sort_by_key(|cell| core::cmp::Reverse(cell.0));
381        for (index, text) in cells {
382            self.push(insert_text_request(index, text));
383        }
384
385        self.cursor =
386            index_after_empty_table(start, row_count, col_count).saturating_add(text_units);
387    }
388
389    /// Project a horizontal rule as a degraded glyph paragraph.
390    fn horizontal_rule(&mut self, parent: &str) {
391        let id = self.next_id(parent, ElementKind::HorizontalRule);
392        let start = self.cursor;
393        self.record(&id, ElementKind::HorizontalRule, start);
394        let (range_start, range_end) = self.insert_line(RULE_GLYPHS);
395        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
396    }
397
398    /// Insert `text` followed by a newline at the cursor; return the `[start, end)`
399    /// range the inserted line occupies and advance the cursor.
400    fn insert_line(&mut self, text: &str) -> (u32, u32) {
401        let start = self.cursor;
402        let mut line = String::with_capacity(text.len() + 1);
403        line.push_str(text);
404        line.push('\n');
405        let end = self.emit_text(line);
406        (start, end)
407    }
408
409    /// Push an `InsertText` of `text` at the cursor, advance the cursor past it,
410    /// and return the new cursor position.
411    fn emit_text(&mut self, text: String) -> u32 {
412        let start = self.cursor;
413        let end = start.saturating_add(len_u32(&text));
414        self.push(insert_text_request(start, text));
415        self.cursor = end;
416        end
417    }
418
419    /// Emit an `UpdateTextStyle` request for each inline span, offsetting span
420    /// positions by `base` (the absolute index of the span text's start).
421    fn styles(&mut self, base: u32, spans: &[Span]) {
422        for span in spans {
423            let start = base.saturating_add(span.start);
424            let end = base.saturating_add(span.end);
425            if end <= start {
426                continue;
427            }
428            self.push(text_style_request(start, end, &span.style));
429        }
430    }
431
432    fn record(&mut self, id: &str, kind: ElementKind, index: u32) {
433        self.positions
434            .insert(id.to_owned(), Position { index, kind });
435    }
436
437    /// Mint the next hierarchical id for a non-heading element under `parent`.
438    fn next_id(&mut self, parent: &str, kind: ElementKind) -> String {
439        let key = format!("{parent}/{}", kind.slug());
440        let count = self.counters.entry(key).or_insert(0);
441        *count = count.saturating_add(1);
442        format!("{parent}/{}-{count}", kind.slug())
443    }
444
445    fn push(&mut self, request: Request) {
446        self.requests.push(request);
447    }
448}
449
450// ── Inline flattening ───────────────────────────────────────────────────────
451
452/// A run of styled text within a paragraph, with UTF-16 offsets relative to the
453/// start of the flattened text.
454struct Span {
455    style: Style,
456    start: u32,
457    end: u32,
458}
459
460impl Span {
461    /// Return a copy of this span shifted right by `offset` code units.
462    fn shifted(self, offset: u32) -> Self {
463        Self {
464            style: self.style,
465            start: self.start.saturating_add(offset),
466            end: self.end.saturating_add(offset),
467        }
468    }
469}
470
471/// A text style applied to a [`Span`].
472enum Style {
473    Bold,
474    Italic,
475    Strikethrough,
476    Monospace,
477    Link(String),
478}
479
480/// The visible projected text of an inline sequence (styling discarded).
481///
482/// Shared with [`crate::comments`] (Q1): the quoted-text fallback matches a
483/// Google comment's `quotedFileContent` against the same characters projection
484/// sends to the doc, so it must flatten inlines identically.
485pub(crate) fn inline_text(inlines: &[Inline]) -> String {
486    flatten(inlines).0
487}
488
489/// Flatten inline nodes into their visible text plus the styled spans over it.
490fn flatten(inlines: &[Inline]) -> (String, Vec<Span>) {
491    let mut text = String::new();
492    let mut spans = Vec::new();
493    flatten_into(inlines, &mut text, &mut spans);
494    (text, spans)
495}
496
497/// Flatten the inline content of a list item's blocks into one line of text.
498/// Multiple paragraphs are space-joined; nested lists contribute their items'
499/// text in order.
500fn flatten_blocks(blocks: &[Block]) -> (String, Vec<Span>) {
501    let mut text = String::new();
502    let mut spans = Vec::new();
503    collect_block_inlines(blocks, &mut text, &mut spans);
504    (text, spans)
505}
506
507fn collect_block_inlines(blocks: &[Block], text: &mut String, spans: &mut Vec<Span>) {
508    for block in blocks {
509        match block {
510            Block::Paragraph { inlines } => {
511                if !text.is_empty() {
512                    text.push(' ');
513                }
514                flatten_into(inlines, text, spans);
515            }
516            Block::List { items, .. } => {
517                for item in items {
518                    collect_block_inlines(&item.content, text, spans);
519                }
520            }
521            _ => {}
522        }
523    }
524}
525
526fn flatten_into(inlines: &[Inline], text: &mut String, spans: &mut Vec<Span>) {
527    for inline in inlines {
528        match inline {
529            Inline::Plain(value) => text.push_str(value),
530            Inline::Bold(children) => wrap(children, Style::Bold, text, spans),
531            Inline::Italic(children) => wrap(children, Style::Italic, text, spans),
532            Inline::Strikethrough(children) => wrap(children, Style::Strikethrough, text, spans),
533            Inline::InlineCode(value) | Inline::Verbatim(value) => {
534                push_span(value, Style::Monospace, text, spans);
535            }
536            Inline::LineBreak => {
537                // A soft source wrap is whitespace, never a paragraph break: the
538                // org parser emits `LineBreak` only to round-trip source wrapping
539                // (a real boundary is a blank line, which ends the block). Collapse
540                // it to a single space, but skip the push when the text already ends
541                // in whitespace so a wrap after a trailing space does not double it.
542                if !text.ends_with(char::is_whitespace) {
543                    text.push(' ');
544                }
545            }
546            Inline::Link {
547                target,
548                description,
549            } => {
550                let visible = description.as_deref().unwrap_or(target);
551                push_span(visible, Style::Link(target.clone()), text, spans);
552            }
553        }
554    }
555}
556
557/// Append `children`'s flattened text, recording a span of `style` over it.
558fn wrap(children: &[Inline], style: Style, text: &mut String, spans: &mut Vec<Span>) {
559    let start = len_u32(text);
560    flatten_into(children, text, spans);
561    let end = len_u32(text);
562    spans.push(Span { style, start, end });
563}
564
565/// Append literal `value`, recording a span of `style` over it.
566fn push_span(value: &str, style: Style, text: &mut String, spans: &mut Vec<Span>) {
567    let start = len_u32(text);
568    text.push_str(value);
569    let end = len_u32(text);
570    spans.push(Span { style, start, end });
571}
572
573// ── Id and style helpers ────────────────────────────────────────────────────
574
575/// The id for a heading: its `:CUSTOM_ID:` property when present (P2 inserts it
576/// in the body), else the deterministic `section_id` of its title.
577///
578/// Shared with [`crate::comments`] (Q1) so the section keys it builds for the
579/// quoted-text fallback match the keys [`project`] records in the position map.
580pub(crate) fn heading_id(title: &Title, children: &[Block]) -> String {
581    children
582        .iter()
583        .find_map(|block| match block {
584            Block::PropertyDrawer { entries } => entries.iter().find_map(|(key, value)| {
585                key.eq_ignore_ascii_case("CUSTOM_ID")
586                    .then(|| value.trim().to_owned())
587            }),
588            _ => None,
589        })
590        .filter(|id| !id.is_empty())
591        .unwrap_or_else(|| section_id(title.as_str()))
592}
593
594/// The `HEADING_n` named style for an org heading level, clamped to 1..=6.
595fn heading_style(level: u8) -> String {
596    format!("HEADING_{}", level.clamp(1, 6))
597}
598
599/// The checkbox marker prefixed to a list item, or `""` for a plain item.
600const fn checkbox_marker(checkbox: &Checkbox) -> &'static str {
601    match checkbox {
602        Checkbox::Unchecked => MARK_UNCHECKED,
603        Checkbox::Checked => MARK_CHECKED,
604        Checkbox::NoCheckbox => "",
605    }
606}
607
608/// Whether `row` is an org table rule row (`|---+---|`), which the projection
609/// drops rather than rendering as a data row.
610///
611/// A rule row is non-empty and every cell, once its flattened text is trimmed,
612/// is non-empty and composed solely of the org rule characters `-`, `+`, `|`
613/// (the `|` covers a cell that itself spans an inner column boundary).
614fn is_rule_row(row: &[TableCell]) -> bool {
615    !row.is_empty()
616        && row.iter().all(|cell| {
617            let text = inline_text(&cell.inlines);
618            let trimmed = text.trim();
619            !trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '-' | '+' | '|'))
620        })
621}
622
623// ── Table index arithmetic ──────────────────────────────────────────────────
624//
625// Inserting an `R×C` table at location index `L` yields the empty-table layout
626// (Google Docs API, confirmed against a live round-trip — see the
627// `live_probe_table` test notes): Google inserts a newline paragraph *before* the
628// table (table start = L+1, first cell paragraph = L+4), each empty cell costs two
629// index units (cell boundary + empty paragraph), each row adds a one-unit
630// boundary, and a trailing paragraph follows the table.
631
632/// The index at which to insert text into the empty cell at `(row, col)` of a
633/// `cols`-wide table inserted at `table_loc`.
634///
635/// The `+ 4` absorbs the pre-table newline plus the table/row/cell openings;
636/// verified live (a 3×2 probe placed cell `(0,0)` at `L + 4`).
637const fn cell_content_index(table_loc: u32, row: u32, col: u32, cols: u32) -> u32 {
638    table_loc + 4 + row * (1 + 2 * cols) + col * 2
639}
640
641/// The index of the paragraph immediately after an *empty* `rows`×`cols` table
642/// inserted at `table_loc` (before any cell text is inserted).
643///
644/// The constant is `+ 3`, not `+ 2`: the empty table spans `[L+1, L+2+R(1+2C))`,
645/// and Google's pre-table newline shifts the trailing paragraph one further. A
646/// live 3×2 probe (`L = 1`) reported the post-table paragraph at index 19
647/// (`1 + 3 + 3·5`); the earlier `+ 2` (→ 18) mis-indexed every request after a
648/// table, which only a live push surfaced.
649const fn index_after_empty_table(table_loc: u32, rows: u32, cols: u32) -> u32 {
650    table_loc + 3 + rows * (1 + 2 * cols)
651}
652
653// ── Typed request constructors ──────────────────────────────────────────────
654
655fn location(index: u32) -> Location {
656    Location {
657        index: Some(to_i32(index)),
658        segment_id: None,
659        tab_id: None,
660    }
661}
662
663fn range(start: u32, end: u32) -> Range {
664    Range {
665        start_index: Some(to_i32(start)),
666        end_index: Some(to_i32(end)),
667        segment_id: None,
668        tab_id: None,
669    }
670}
671
672fn insert_text_request(index: u32, text: String) -> Request {
673    Request {
674        insert_text: Some(InsertTextRequest {
675            location: Some(location(index)),
676            text: Some(text),
677            end_of_segment_location: None,
678        }),
679        ..Request::default()
680    }
681}
682
683fn insert_table_request(location_index: u32, rows: u32, cols: u32) -> Request {
684    Request {
685        insert_table: Some(InsertTableRequest {
686            location: Some(location(location_index)),
687            rows: Some(to_i32(rows)),
688            columns: Some(to_i32(cols)),
689            end_of_segment_location: None,
690        }),
691        ..Request::default()
692    }
693}
694
695fn named_style_request(start: u32, end: u32, named_style: &str) -> Request {
696    Request {
697        update_paragraph_style: Some(UpdateParagraphStyleRequest {
698            range: Some(range(start, end)),
699            paragraph_style: Some(ParagraphStyle {
700                named_style_type: Some(named_style.to_owned()),
701                ..ParagraphStyle::default()
702            }),
703            fields: Some(FieldMask::new(&["namedStyleType"])),
704        }),
705        ..Request::default()
706    }
707}
708
709fn indented_style_request(start: u32, end: u32) -> Request {
710    Request {
711        update_paragraph_style: Some(UpdateParagraphStyleRequest {
712            range: Some(range(start, end)),
713            paragraph_style: Some(ParagraphStyle {
714                named_style_type: Some("NORMAL_TEXT".to_owned()),
715                indent_start: Some(Dimension {
716                    magnitude: Some(36.0),
717                    unit: Some("PT".to_owned()),
718                }),
719                ..ParagraphStyle::default()
720            }),
721            fields: Some(FieldMask::new(&["namedStyleType", "indentStart"])),
722        }),
723        ..Request::default()
724    }
725}
726
727fn bullets_request(start: u32, end: u32, list_type: &ListType) -> Request {
728    let preset = match list_type {
729        ListType::Ordered(_) => "NUMBERED_DECIMAL_ALPHA_ROMAN",
730        ListType::Unordered => "BULLET_DISC_CIRCLE_SQUARE",
731    };
732    Request {
733        create_paragraph_bullets: Some(CreateParagraphBulletsRequest {
734            range: Some(range(start, end)),
735            bullet_preset: Some(preset.to_owned()),
736        }),
737        ..Request::default()
738    }
739}
740
741fn text_style_request(start: u32, end: u32, style: &Style) -> Request {
742    let (text_style, fields) = match style {
743        Style::Bold => (
744            TextStyle {
745                bold: Some(true),
746                ..TextStyle::default()
747            },
748            "bold",
749        ),
750        Style::Italic => (
751            TextStyle {
752                italic: Some(true),
753                ..TextStyle::default()
754            },
755            "italic",
756        ),
757        Style::Strikethrough => (
758            TextStyle {
759                strikethrough: Some(true),
760                ..TextStyle::default()
761            },
762            "strikethrough",
763        ),
764        Style::Monospace => (
765            TextStyle {
766                weighted_font_family: Some(WeightedFontFamily {
767                    font_family: Some(MONOSPACE_FONT.to_owned()),
768                    weight: Some(400),
769                }),
770                ..TextStyle::default()
771            },
772            "weightedFontFamily",
773        ),
774        Style::Link(url) => (
775            TextStyle {
776                link: Some(Link {
777                    url: Some(url.clone()),
778                    ..Link::default()
779                }),
780                ..TextStyle::default()
781            },
782            "link",
783        ),
784    };
785    Request {
786        update_text_style: Some(UpdateTextStyleRequest {
787            range: Some(range(start, end)),
788            text_style: Some(text_style),
789            fields: Some(FieldMask::new(&[fields])),
790        }),
791        ..Request::default()
792    }
793}
794
795// ── Numeric helpers (UTF-16, saturating, panic-free per EI-2) ────────────────
796
797/// UTF-16 length of `text` as a `u32`, saturating (documents never approach the
798/// limit; saturation avoids a panic on the impossible case).
799fn len_u32(text: &str) -> u32 {
800    u32::try_from(len_utf16(text)).unwrap_or(u32::MAX)
801}
802
803/// A collection length as a `u32`, saturating.
804fn len_u32_of(value: usize) -> u32 {
805    u32::try_from(value).unwrap_or(u32::MAX)
806}
807
808/// A `u32` index as the API's `i32`, saturating.
809fn to_i32(value: u32) -> i32 {
810    i32::try_from(value).unwrap_or(i32::MAX)
811}
812
813#[cfg(test)]
814mod tests {
815    use super::{ElementKind, Style, project, text_style_request};
816    use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
817
818    fn heading(level: u8, title: &str, children: Vec<Block>) -> Block {
819        Block::Heading {
820            level,
821            title: Title(title.to_owned()),
822            tags: vec![],
823            children,
824        }
825    }
826
827    fn custom_id_drawer(id: &str) -> Block {
828        Block::PropertyDrawer {
829            entries: vec![("CUSTOM_ID".to_owned(), format!(" {id}"))],
830        }
831    }
832
833    fn paragraph(inlines: Vec<Inline>) -> Block {
834        Block::Paragraph { inlines }
835    }
836
837    fn plain(text: &str) -> Inline {
838        Inline::Plain(text.to_owned())
839    }
840
841    fn doc(blocks: Vec<Block>) -> Document {
842        Document { blocks }
843    }
844
845    #[test]
846    fn heading_emits_insert_then_named_style() {
847        let projection = project(&doc(vec![heading(2, "Hi", vec![])]));
848        assert_eq!(projection.requests.len(), 2);
849
850        let insert = projection.requests[0].insert_text.as_ref().expect("insert");
851        assert_eq!(insert.text.as_deref(), Some("Hi\n"));
852        assert_eq!(insert.location.as_ref().and_then(|l| l.index), Some(1));
853
854        let style = projection.requests[1]
855            .update_paragraph_style
856            .as_ref()
857            .expect("style");
858        assert_eq!(
859            style
860                .paragraph_style
861                .as_ref()
862                .and_then(|p| p.named_style_type.as_deref()),
863            Some("HEADING_2")
864        );
865        let range = style.range.as_ref().expect("range");
866        assert_eq!((range.start_index, range.end_index), (Some(1), Some(4)));
867    }
868
869    #[test]
870    fn heading_level_clamped_to_six() {
871        let projection = project(&doc(vec![heading(9, "Deep", vec![])]));
872        let style = projection.requests[1]
873            .update_paragraph_style
874            .as_ref()
875            .unwrap();
876        assert_eq!(
877            style
878                .paragraph_style
879                .as_ref()
880                .and_then(|p| p.named_style_type.as_deref()),
881            Some("HEADING_6")
882        );
883    }
884
885    #[test]
886    fn paragraph_nested_bold_italic_and_link() {
887        // "a " + bold(italic("bc")) + " " + link("d")  => "a bc d\n"
888        let para = paragraph(vec![
889            plain("a "),
890            Inline::Bold(vec![Inline::Italic(vec![plain("bc")])]),
891            plain(" "),
892            Inline::Link {
893                target: "https://x".to_owned(),
894                description: Some("d".to_owned()),
895            },
896        ]);
897        let projection = project(&doc(vec![para]));
898
899        let insert = projection.requests[0].insert_text.as_ref().unwrap();
900        assert_eq!(insert.text.as_deref(), Some("a bc d\n"));
901        assert_eq!(
902            projection.requests[1]
903                .update_paragraph_style
904                .as_ref()
905                .and_then(|s| s.paragraph_style.as_ref())
906                .and_then(|p| p.named_style_type.as_deref()),
907            Some("NORMAL_TEXT")
908        );
909
910        // Inner italic span pushed before the enclosing bold span; both cover "bc"
911        // at indices 2..4. The link covers "d" at 5..6.
912        let italic = projection.requests[2].update_text_style.as_ref().unwrap();
913        assert_eq!(
914            italic.text_style.as_ref().and_then(|t| t.italic),
915            Some(true)
916        );
917        let italic_range = italic.range.as_ref().unwrap();
918        assert_eq!(
919            (italic_range.start_index, italic_range.end_index),
920            (Some(3), Some(5))
921        );
922
923        let bold = projection.requests[3].update_text_style.as_ref().unwrap();
924        assert_eq!(bold.text_style.as_ref().and_then(|t| t.bold), Some(true));
925        let bold_range = bold.range.as_ref().unwrap();
926        assert_eq!(
927            (bold_range.start_index, bold_range.end_index),
928            (Some(3), Some(5))
929        );
930
931        let link = projection.requests[4].update_text_style.as_ref().unwrap();
932        assert_eq!(
933            link.text_style
934                .as_ref()
935                .and_then(|t| t.link.as_ref())
936                .and_then(|l| l.url.as_deref()),
937            Some("https://x")
938        );
939        let link_range = link.range.as_ref().unwrap();
940        assert_eq!(
941            (link_range.start_index, link_range.end_index),
942            (Some(6), Some(7))
943        );
944    }
945
946    #[test]
947    fn inline_code_is_monospace() {
948        let projection = project(&doc(vec![paragraph(vec![Inline::InlineCode(
949            "x".to_owned(),
950        )])]));
951        let style = projection.requests[2].update_text_style.as_ref().unwrap();
952        assert_eq!(
953            style
954                .text_style
955                .as_ref()
956                .and_then(|t| t.weighted_font_family.as_ref())
957                .and_then(|f| f.font_family.as_deref()),
958            Some("Courier New")
959        );
960    }
961
962    #[test]
963    fn unordered_list_inserts_joined_text_and_bullets() {
964        let list = Block::List {
965            list_type: ListType::Unordered,
966            items: vec![
967                ListItem {
968                    content: vec![paragraph(vec![plain("A")])],
969                    checkbox: Checkbox::NoCheckbox,
970                },
971                ListItem {
972                    content: vec![paragraph(vec![plain("B")])],
973                    checkbox: Checkbox::NoCheckbox,
974                },
975            ],
976        };
977        let projection = project(&doc(vec![list]));
978        assert_eq!(
979            projection.requests[0]
980                .insert_text
981                .as_ref()
982                .unwrap()
983                .text
984                .as_deref(),
985            Some("A\nB\n")
986        );
987        let bullets = projection.requests[1]
988            .create_paragraph_bullets
989            .as_ref()
990            .unwrap();
991        assert_eq!(
992            bullets.bullet_preset.as_deref(),
993            Some("BULLET_DISC_CIRCLE_SQUARE")
994        );
995    }
996
997    #[test]
998    fn ordered_list_uses_numbered_preset() {
999        let list = Block::List {
1000            list_type: ListType::Ordered(1),
1001            items: vec![ListItem {
1002                content: vec![paragraph(vec![plain("only")])],
1003                checkbox: Checkbox::NoCheckbox,
1004            }],
1005        };
1006        let projection = project(&doc(vec![list]));
1007        assert_eq!(
1008            projection.requests[1]
1009                .create_paragraph_bullets
1010                .as_ref()
1011                .unwrap()
1012                .bullet_preset
1013                .as_deref(),
1014            Some("NUMBERED_DECIMAL_ALPHA_ROMAN")
1015        );
1016    }
1017
1018    #[test]
1019    fn checkbox_list_marks_state_and_omits_bullets() {
1020        let list = Block::List {
1021            list_type: ListType::Unordered,
1022            items: vec![
1023                ListItem {
1024                    content: vec![paragraph(vec![plain("done")])],
1025                    checkbox: Checkbox::Checked,
1026                },
1027                ListItem {
1028                    content: vec![paragraph(vec![plain("todo")])],
1029                    checkbox: Checkbox::Unchecked,
1030                },
1031            ],
1032        };
1033        let projection = project(&doc(vec![list]));
1034        assert_eq!(
1035            projection.requests[0]
1036                .insert_text
1037                .as_ref()
1038                .unwrap()
1039                .text
1040                .as_deref(),
1041            Some("\u{2611} done\n\u{2610} todo\n")
1042        );
1043        // No bullets request follows the insert for a checkbox list.
1044        assert!(
1045            projection
1046                .requests
1047                .iter()
1048                .all(|r| r.create_paragraph_bullets.is_none())
1049        );
1050    }
1051
1052    #[test]
1053    fn table_inserts_skeleton_then_cells_descending() {
1054        let cell = |text: &str| TableCell {
1055            inlines: vec![plain(text)],
1056        };
1057        let table = Block::Table {
1058            rows: vec![vec![cell("A1"), cell("B1")], vec![cell("A2"), cell("B2")]],
1059        };
1060        // A paragraph follows the table so the post-table cursor is asserted: the
1061        // bug a live push caught (every request after a table was off by one) was
1062        // invisible while the table was the only block.
1063        let projection = project(&doc(vec![
1064            table,
1065            Block::Paragraph {
1066                inlines: vec![plain("after")],
1067            },
1068        ]));
1069
1070        let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
1071        assert_eq!(
1072            (insert_table.rows, insert_table.columns),
1073            (Some(2), Some(2))
1074        );
1075        assert_eq!(
1076            insert_table.location.as_ref().and_then(|l| l.index),
1077            Some(1)
1078        );
1079
1080        // Cell inserts in descending index order: B2@12, A2@10, B1@7, A1@5.
1081        let cells = &projection.requests[1..5];
1082        let cell_indices: Vec<(Option<i32>, Option<&str>)> = cells
1083            .iter()
1084            .map(|r| {
1085                let insert = r.insert_text.as_ref().unwrap();
1086                (
1087                    insert.location.as_ref().and_then(|l| l.index),
1088                    insert.text.as_deref(),
1089                )
1090            })
1091            .collect();
1092        assert_eq!(
1093            cell_indices,
1094            vec![
1095                (Some(12), Some("B2")),
1096                (Some(10), Some("A2")),
1097                (Some(7), Some("B1")),
1098                (Some(5), Some("A1")),
1099            ]
1100        );
1101
1102        // The paragraph after the table inserts at the post-table cursor:
1103        // empty 2×2 table ends at 1 + 3 + 2·5 = 14, plus 8 units of cell text → 22.
1104        // (Live-validated geometry; the pre-fix `+ 2` would have put this at 21.)
1105        let after = projection.requests[5].insert_text.as_ref().unwrap();
1106        assert_eq!(after.text.as_deref(), Some("after\n"));
1107        assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
1108
1109        // Position recorded at the table start.
1110        let position = projection.positions.get("doc/table-1").unwrap();
1111        assert_eq!((position.index, position.kind), (1, ElementKind::Table));
1112    }
1113
1114    #[test]
1115    fn src_block_is_monospace() {
1116        let projection = project(&doc(vec![Block::SrcBlock {
1117            language: "rust".to_owned(),
1118            content: "fn main() {}".to_owned(),
1119        }]));
1120        assert_eq!(
1121            projection.requests[0]
1122                .insert_text
1123                .as_ref()
1124                .unwrap()
1125                .text
1126                .as_deref(),
1127            Some("fn main() {}\n")
1128        );
1129        assert_eq!(
1130            projection.requests[2]
1131                .update_text_style
1132                .as_ref()
1133                .and_then(|s| s.text_style.as_ref())
1134                .and_then(|t| t.weighted_font_family.as_ref())
1135                .and_then(|f| f.font_family.as_deref()),
1136            Some("Courier New")
1137        );
1138    }
1139
1140    #[test]
1141    fn horizontal_rule_degrades_to_glyphs() {
1142        let projection = project(&doc(vec![Block::HorizontalRule]));
1143        let text = projection.requests[0]
1144            .insert_text
1145            .as_ref()
1146            .unwrap()
1147            .text
1148            .as_deref();
1149        assert!(text.is_some_and(|t| t.starts_with('\u{2500}')));
1150        assert!(projection.positions.contains_key("doc/hr-1"));
1151    }
1152
1153    #[test]
1154    fn quote_paragraphs_are_indented() {
1155        let quote = Block::QuoteBlock {
1156            children: vec![paragraph(vec![plain("quoted")])],
1157        };
1158        let projection = project(&doc(vec![quote]));
1159        let style = projection.requests[1]
1160            .update_paragraph_style
1161            .as_ref()
1162            .unwrap();
1163        assert!(
1164            style
1165                .paragraph_style
1166                .as_ref()
1167                .and_then(|p| p.indent_start.as_ref())
1168                .is_some()
1169        );
1170        assert!(projection.positions.contains_key("doc/quote-1"));
1171    }
1172
1173    #[test]
1174    fn position_map_uses_custom_id_and_hierarchical_ids() {
1175        let body = heading(
1176            1,
1177            "Intro",
1178            vec![
1179                custom_id_drawer("sec-intro"),
1180                paragraph(vec![plain("first")]),
1181                paragraph(vec![plain("second")]),
1182            ],
1183        );
1184        let projection = project(&doc(vec![body]));
1185
1186        let head = projection.positions.get("sec-intro").expect("heading id");
1187        assert_eq!((head.index, head.kind), (1, ElementKind::Heading));
1188
1189        // Paragraphs under the heading are keyed by <parent>/<type>-<n>.
1190        assert!(projection.positions.contains_key("sec-intro/paragraph-1"));
1191        assert!(projection.positions.contains_key("sec-intro/paragraph-2"));
1192
1193        // Indices advance monotonically through the document.
1194        let p1 = projection.positions["sec-intro/paragraph-1"].index;
1195        let p2 = projection.positions["sec-intro/paragraph-2"].index;
1196        assert!(head.index < p1 && p1 < p2);
1197    }
1198
1199    #[test]
1200    fn metadata_blocks_produce_no_requests() {
1201        let blocks = vec![
1202            Block::BlankLine,
1203            Block::Comment {
1204                text: "hidden".to_owned(),
1205            },
1206            Block::Keyword {
1207                name: "TITLE".to_owned(),
1208                value: " Doc".to_owned(),
1209            },
1210        ];
1211        let projection = project(&doc(blocks));
1212        assert!(projection.requests.is_empty());
1213        assert!(projection.positions.is_empty());
1214    }
1215
1216    #[test]
1217    fn empty_table_emits_no_requests() {
1218        let projection = project(&doc(vec![Block::Table { rows: vec![] }]));
1219        assert!(projection.requests.is_empty());
1220    }
1221
1222    #[test]
1223    fn paragraph_soft_line_breaks_become_spaces() {
1224        let para = paragraph(vec![
1225            plain("first line"),
1226            Inline::LineBreak,
1227            plain("second line"),
1228        ]);
1229        let projection = project(&doc(vec![para]));
1230        let insert = projection.requests[0].insert_text.as_ref().unwrap();
1231        // The soft wrap is a single space; the only newline is the line terminator.
1232        assert_eq!(insert.text.as_deref(), Some("first line second line\n"));
1233    }
1234
1235    #[test]
1236    fn soft_break_after_trailing_space_does_not_double() {
1237        let para = paragraph(vec![plain("first "), Inline::LineBreak, plain("second")]);
1238        let projection = project(&doc(vec![para]));
1239        let insert = projection.requests[0].insert_text.as_ref().unwrap();
1240        assert_eq!(insert.text.as_deref(), Some("first second\n"));
1241    }
1242
1243    #[test]
1244    fn list_item_soft_line_break_stays_one_bullet() {
1245        let list = Block::List {
1246            list_type: ListType::Unordered,
1247            items: vec![ListItem {
1248                content: vec![paragraph(vec![
1249                    plain("wrapped"),
1250                    Inline::LineBreak,
1251                    plain("item"),
1252                ])],
1253                checkbox: Checkbox::NoCheckbox,
1254            }],
1255        };
1256        let projection = project(&doc(vec![list]));
1257        let insert = projection.requests[0].insert_text.as_ref().unwrap();
1258        // One bullet: the soft break joins with a space, only the item terminator
1259        // newline remains (a second newline would split it into two bullets).
1260        assert_eq!(insert.text.as_deref(), Some("wrapped item\n"));
1261    }
1262
1263    #[test]
1264    fn table_drops_rule_row() {
1265        let cell = |text: &str| TableCell {
1266            inlines: vec![plain(text)],
1267        };
1268        let table = Block::Table {
1269            rows: vec![
1270                vec![cell("A1"), cell("B1")],
1271                // An org rule row parses to a single cell of `---+---`.
1272                vec![cell("---+---")],
1273                vec![cell("A2"), cell("B2")],
1274            ],
1275        };
1276        let projection = project(&doc(vec![
1277            table,
1278            Block::Paragraph {
1279                inlines: vec![plain("after")],
1280            },
1281        ]));
1282
1283        // The rule row is dropped: a 2×2 table, not 3×N.
1284        let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
1285        assert_eq!(
1286            (insert_table.rows, insert_table.columns),
1287            (Some(2), Some(2))
1288        );
1289
1290        // No inserted cell carries the rule glyphs.
1291        assert!(projection.requests.iter().all(|r| {
1292            r.insert_text
1293                .as_ref()
1294                .and_then(|i| i.text.as_deref())
1295                .is_none_or(|t| !t.contains('+'))
1296        }));
1297
1298        // Geometry matches a clean 2×2 table (no trace of the dropped rule row):
1299        // empty table ends at 1+3+2·5 = 14, plus 8 units of cell text
1300        // ("A1"+"B1"+"A2"+"B2") → the paragraph at 22, as in the skeleton test.
1301        let after = projection
1302            .requests
1303            .iter()
1304            .filter_map(|r| r.insert_text.as_ref())
1305            .find(|i| i.text.as_deref() == Some("after\n"))
1306            .unwrap();
1307        assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
1308    }
1309
1310    #[test]
1311    fn table_of_only_rule_row_emits_no_requests() {
1312        let table = Block::Table {
1313            rows: vec![vec![TableCell {
1314                inlines: vec![plain("---+---")],
1315            }]],
1316        };
1317        let projection = project(&doc(vec![table]));
1318        assert!(projection.requests.is_empty());
1319    }
1320
1321    #[test]
1322    fn text_style_request_sets_strikethrough() {
1323        let request = text_style_request(1, 3, &Style::Strikethrough);
1324        assert_eq!(
1325            request
1326                .update_text_style
1327                .and_then(|s| s.text_style)
1328                .and_then(|t| t.strikethrough),
1329            Some(true)
1330        );
1331    }
1332}