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