Skip to main content

omni_dev/drive/docs/
table.rs

1//! Bounded table edits resolved from the revision-pinned inline snapshot.
2//! One request per invocation prevents index shifts from request ordering.
3
4use serde::Serialize;
5
6use super::anchor::{self, AnchorError, Side};
7use super::types::{Document, StructuralElement};
8use super::write_types::{
9    DocsRequest, InsertTableColumnRequest, InsertTableRequest, InsertTableRowRequest, Location,
10    TableCellLocation, TableDimensionRequest,
11};
12use crate::drive::write_gate::DriveOperation;
13
14/// The five supported table operations.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum TableVerb {
18    /// Insert an empty table.
19    InsertTable,
20    /// Insert a row.
21    InsertRow,
22    /// Insert a column.
23    InsertColumn,
24    /// Remove a row, retaining the table.
25    DeleteRow,
26    /// Remove a column, retaining the table.
27    DeleteColumn,
28}
29
30impl TableVerb {
31    /// CLI and audit spelling.
32    #[must_use]
33    pub const fn label(self) -> &'static str {
34        match self {
35            Self::InsertTable => "insert-table",
36            Self::InsertRow => "insert-table-row",
37            Self::InsertColumn => "insert-table-column",
38            Self::DeleteRow => "delete-table-row",
39            Self::DeleteColumn => "delete-table-column",
40        }
41    }
42
43    /// Independent consent for additions and destructive edits.
44    #[must_use]
45    pub const fn gate_operation(self) -> DriveOperation {
46        match self {
47            Self::DeleteRow | Self::DeleteColumn => DriveOperation::DocsTableDelete,
48            _ => DriveOperation::DocsStructure,
49        }
50    }
51
52    pub(super) const fn log_operation(self) -> &'static str {
53        match self {
54            Self::InsertTable => "docs-insert-table",
55            Self::InsertRow => "docs-insert-table-row",
56            Self::InsertColumn => "docs-insert-table-column",
57            Self::DeleteRow => "docs-delete-table-row",
58            Self::DeleteColumn => "docs-delete-table-column",
59        }
60    }
61}
62
63/// Typed caller intent. No raw index or batch escape hatch.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum TableEdit {
66    /// Insert next to a unique top-level body anchor.
67    Insert {
68        /// Literal anchor.
69        anchor: String,
70        /// Before or after the anchor.
71        side: Side,
72        /// Empty rows to create.
73        rows: i64,
74        /// Empty columns to create.
75        columns: i64,
76        /// Case-sensitive anchor matching.
77        match_case: bool,
78    },
79    /// Edit the row/column containing a unique cell anchor.
80    Dimension {
81        /// Operation (InsertTable is rejected here).
82        verb: TableVerb,
83        /// Literal unique text in the reference cell.
84        cell: String,
85        /// Below/right for insertion; unused for deletion.
86        after: bool,
87        /// Case-sensitive matching.
88        match_case: bool,
89    },
90}
91
92impl TableEdit {
93    /// The operation this intent describes.
94    #[must_use]
95    pub const fn verb(&self) -> TableVerb {
96        match self {
97            Self::Insert { .. } => TableVerb::InsertTable,
98            Self::Dimension { verb, .. } => *verb,
99        }
100    }
101
102    pub(super) fn validate(&self) -> Result<(), String> {
103        let anchor = match self {
104            Self::Insert {
105                anchor,
106                rows,
107                columns,
108                ..
109            } => {
110                // Bound allocation explicitly; no unbounded empty grid creation.
111                if *rows < 1
112                    || *columns < 1
113                    || rows.checked_mul(*columns).is_none_or(|n| n > 10_000)
114                {
115                    return Err(
116                        "table must have positive dimensions and at most 10000 cells".into(),
117                    );
118                }
119                anchor
120            }
121            Self::Dimension {
122                verb: TableVerb::InsertTable,
123                ..
124            } => {
125                return Err("table insertion requires dimensions and a body anchor".into());
126            }
127            Self::Dimension { cell, .. } => cell,
128        };
129        if anchor.is_empty() || anchor.contains(['\n', '\r']) {
130            return Err("anchor must be nonempty and within one paragraph".into());
131        }
132        Ok(())
133    }
134}
135
136/// A table operation's exact resolved effect, without user prose.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct TablePreview {
139    /// The operation.
140    pub operation: TableVerb,
141    /// Insertion point for a new table, table start for dimension edits.
142    pub location: Location,
143    /// Reference row, for dimension edits.
144    pub row_index: Option<i64>,
145    /// Reference column, for dimension edits.
146    pub column_index: Option<i64>,
147    /// Existing rows (zero for a new table).
148    pub rows_before: i64,
149    /// Existing columns (zero for a new table).
150    pub columns_before: i64,
151    /// Resulting row count.
152    pub rows_after: i64,
153    /// Resulting column count.
154    pub columns_after: i64,
155    /// New row/column goes below/right of the reference cell.
156    pub insert_after: bool,
157    /// Google inserts a newline before a new table.
158    pub preceding_newline: bool,
159}
160
161/// A table could not be safely addressed.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163#[serde(tag = "reason", rename_all = "kebab-case")]
164pub enum TableError {
165    /// Anchor resolution refused the address.
166    Anchor {
167        /// Anchor refusal detail.
168        error: AnchorError,
169    },
170    /// Anchor is outside the supported top-level paragraph/table boundary.
171    UnsafeBoundary,
172    /// Missing or inconsistent table/row/cell indices or dimensions.
173    InvalidTable,
174    /// A pending suggestion affects the table.
175    SuggestedTable,
176    /// A merged or nested table is outside this bounded tranche.
177    UnsupportedTable,
178    /// Removing the last dimension would delete the table.
179    LastDimension,
180    /// Invalid caller intent.
181    InvalidIntent,
182}
183
184fn bounds(element: &StructuralElement, index: i64) -> bool {
185    element
186        .start_index
187        .is_some_and(|start| start >= 1 && start <= index)
188        && element.end_index.is_some_and(|end| index < end)
189}
190
191/// Build the single request and its preview together, before dry-run branches.
192pub fn resolve(
193    document: &Document,
194    edit: &TableEdit,
195) -> Result<(DocsRequest, TablePreview), TableError> {
196    edit.validate().map_err(|_| TableError::InvalidIntent)?;
197    let (needle, side, match_case) = match edit {
198        TableEdit::Insert {
199            anchor,
200            side,
201            match_case,
202            ..
203        } => (anchor, *side, *match_case),
204        TableEdit::Dimension {
205            cell, match_case, ..
206        } => (cell, Side::Before, *match_case),
207    };
208    let point = anchor::resolve_insert(document, needle, side, "x", match_case)
209        .map_err(|error| TableError::Anchor { error })?;
210    let tabs = document.resolved_tabs();
211    let body = tabs
212        .iter()
213        .find(|tab| tab.tab_id == point.tab_id.as_deref())
214        .and_then(|tab| tab.body)
215        .ok_or(TableError::UnsafeBoundary)?;
216    let elements: Vec<_> = body
217        .content
218        .iter()
219        .filter(|e| bounds(e, point.start_index))
220        .collect();
221    if elements.len() != 1 {
222        return Err(TableError::UnsafeBoundary);
223    }
224    let element = elements[0];
225    let verb = edit.verb();
226    let mut preview = TablePreview {
227        operation: verb,
228        location: Location {
229            index: point.start_index,
230            tab_id: point.tab_id,
231            segment_id: None,
232        },
233        row_index: None,
234        column_index: None,
235        rows_before: 0,
236        columns_before: 0,
237        rows_after: 0,
238        columns_after: 0,
239        insert_after: false,
240        preceding_newline: false,
241    };
242    if let TableEdit::Insert { rows, columns, .. } = edit {
243        if element.paragraph.is_none()
244            || element.table.is_some()
245            || element.table_of_contents.is_some()
246            || element.section_break.is_some()
247        {
248            return Err(TableError::UnsafeBoundary);
249        }
250        preview.rows_after = *rows;
251        preview.columns_after = *columns;
252        preview.preceding_newline = true;
253        return Ok((
254            DocsRequest::InsertTable(InsertTableRequest {
255                rows: *rows,
256                columns: *columns,
257                location: preview.location.clone(),
258            }),
259            preview,
260        ));
261    }
262    let table = element.table.as_ref().ok_or(TableError::UnsafeBoundary)?;
263    let json = serde_json::to_value(table).map_err(|_| TableError::InvalidTable)?;
264    if table.has_pending_suggestions || super::types::has_suggestions(&json) {
265        return Err(TableError::SuggestedTable);
266    }
267    let rows = table
268        .rows
269        .filter(|n| *n > 0)
270        .ok_or(TableError::InvalidTable)?;
271    let columns = table
272        .columns
273        .filter(|n| *n > 0)
274        .ok_or(TableError::InvalidTable)?;
275    if usize::try_from(rows).ok() != Some(table.table_rows.len()) {
276        return Err(TableError::InvalidTable);
277    }
278    let start = element.start_index.ok_or(TableError::InvalidTable)?;
279    let end = element.end_index.ok_or(TableError::InvalidTable)?;
280    let mut previous_row_end = start + 1;
281    let mut reference = None;
282    for (r, row) in table.table_rows.iter().enumerate() {
283        if usize::try_from(columns).ok() != Some(row.table_cells.len()) {
284            return Err(TableError::UnsupportedTable);
285        }
286        let rs = row.start_index.ok_or(TableError::InvalidTable)?;
287        let re = row.end_index.ok_or(TableError::InvalidTable)?;
288        if rs < previous_row_end || re <= rs || re > end {
289            return Err(TableError::InvalidTable);
290        }
291        previous_row_end = re;
292        let mut previous_cell_end = rs + 1;
293        for (c, cell) in row.table_cells.iter().enumerate() {
294            let cs = cell.start_index.ok_or(TableError::InvalidTable)?;
295            let ce = cell.end_index.ok_or(TableError::InvalidTable)?;
296            if cs < previous_cell_end || ce <= cs || ce > re {
297                return Err(TableError::InvalidTable);
298            }
299            previous_cell_end = ce;
300            if cell.table_cell_style.as_ref().is_some_and(|style| {
301                style.row_span.is_some_and(|n| n != 1) || style.column_span.is_some_and(|n| n != 1)
302            }) {
303                return Err(TableError::UnsupportedTable);
304            }
305            if cell.content.is_empty()
306                || cell.content.iter().any(|e| {
307                    e.paragraph.is_none()
308                        || e.table.is_some()
309                        || e.table_of_contents.is_some()
310                        || e.section_break.is_some()
311                })
312            {
313                return Err(TableError::UnsupportedTable);
314            }
315            let mut previous_paragraph_end = cs + 1;
316            for paragraph in &cell.content {
317                let ps = paragraph.start_index.ok_or(TableError::InvalidTable)?;
318                let pe = paragraph.end_index.ok_or(TableError::InvalidTable)?;
319                if ps != previous_paragraph_end || pe <= ps || pe > ce {
320                    return Err(TableError::InvalidTable);
321                }
322                previous_paragraph_end = pe;
323            }
324            if previous_paragraph_end != ce {
325                return Err(TableError::InvalidTable);
326            }
327            if cs <= preview.location.index && preview.location.index < ce {
328                if reference.is_some() {
329                    return Err(TableError::InvalidTable); // omni-dev: coverage ignore-line reason="rows, cells and paragraphs are each checked above to start at or after the previous one's end, so the half-open cell ranges are pairwise disjoint and at most one contains the index; kept as defence in depth on the write boundary"
330                }
331                reference = Some((r as i64, c as i64));
332            }
333        }
334    }
335    let (row, column) = reference.ok_or(TableError::InvalidTable)?;
336    preview.location.index = start;
337    preview.row_index = Some(row);
338    preview.column_index = Some(column);
339    preview.rows_before = rows;
340    preview.columns_before = columns;
341    preview.rows_after = rows;
342    preview.columns_after = columns;
343    let cell = TableCellLocation {
344        table_start_location: preview.location.clone(),
345        row_index: row,
346        column_index: column,
347    };
348    let TableEdit::Dimension { after, .. } = edit else {
349        return Err(TableError::InvalidIntent); // omni-dev: coverage ignore-line reason="an Insert edit returned from the branch above, so only a Dimension edit reaches here; the else-arm exists only to destructure the already-known variant"
350    };
351    let request = match verb {
352        TableVerb::InsertRow => {
353            preview.rows_after = rows.checked_add(1).ok_or(TableError::InvalidTable)?;
354            preview.insert_after = *after;
355            DocsRequest::InsertTableRow(InsertTableRowRequest {
356                table_cell_location: cell,
357                insert_below: *after,
358            })
359        }
360        TableVerb::InsertColumn => {
361            preview.columns_after = columns.checked_add(1).ok_or(TableError::InvalidTable)?;
362            preview.insert_after = *after;
363            DocsRequest::InsertTableColumn(InsertTableColumnRequest {
364                table_cell_location: cell,
365                insert_right: *after,
366            })
367        }
368        TableVerb::DeleteRow => {
369            if rows == 1 {
370                return Err(TableError::LastDimension);
371            }
372            preview.rows_after -= 1;
373            DocsRequest::DeleteTableRow(TableDimensionRequest {
374                table_cell_location: cell,
375            })
376        }
377        TableVerb::DeleteColumn => {
378            if columns == 1 {
379                return Err(TableError::LastDimension);
380            }
381            preview.columns_after -= 1;
382            DocsRequest::DeleteTableColumn(TableDimensionRequest {
383                table_cell_location: cell,
384            })
385        }
386        TableVerb::InsertTable => return Err(TableError::InvalidIntent), // omni-dev: coverage ignore-line reason="validate already refuses a Dimension edit carrying InsertTable before resolve reads the document, so this arm exists solely for exhaustiveness over TableVerb"
387    };
388    Ok((request, preview))
389}
390
391#[cfg(test)]
392pub(crate) fn fixture() -> serde_json::Value {
393    use serde_json::json;
394    let mut index = 10;
395    let start = index;
396    index += 1;
397    let mut rows = Vec::new();
398    for names in [["😀 Alpha", "Beta"], ["Gamma", "Delta"]] {
399        let rs = index;
400        index += 1;
401        let mut cells = Vec::new();
402        for name in names {
403            let cs = index;
404            index += 1;
405            let ps = index;
406            let text = format!("{name}\n");
407            index += text.encode_utf16().count() as i64;
408            cells.push(json!({"startIndex":cs,"endIndex":index,"tableCellStyle":{"rowSpan":1,"columnSpan":1},"content":[{"startIndex":ps,"endIndex":index,"paragraph":{"elements":[{"startIndex":ps,"endIndex":index,"textRun":{"content":text}}]}}]}));
409        }
410        rows.push(json!({"startIndex":rs,"endIndex":index,"tableCells":cells}));
411    }
412    json!({"revisionId":"rev-table","tabs":[{"documentTab":{"body":{"content":[
413        {"startIndex":1,"endIndex":10,"paragraph":{"elements":[{"startIndex":1,"endIndex":10,"textRun":{"content":"😀 Intro\n"}}]}},
414        {"startIndex":start,"endIndex":index,"table":{"rows":2,"columns":2,"tableRows":rows}}
415    ]}},"tabProperties":{"tabId":"child"}}]})
416}
417
418#[cfg(test)]
419#[allow(clippy::unwrap_used, clippy::expect_used)]
420mod tests {
421    use super::super::write_types::BatchUpdateDocumentRequest;
422    use super::*;
423    use serde_json::json;
424
425    fn doc(value: serde_json::Value) -> Document {
426        serde_json::from_value(value).unwrap()
427    }
428    fn dimension(verb: TableVerb) -> TableEdit {
429        TableEdit::Dimension {
430            verb,
431            cell: "Alpha".into(),
432            after: true,
433            match_case: true,
434        }
435    }
436    fn table(value: &mut serde_json::Value) -> &mut serde_json::Value {
437        &mut value["tabs"][0]["documentTab"]["body"]["content"][1]["table"]
438    }
439
440    #[test]
441    fn table_insertion_uses_utf16_indices_tab_identity_and_preceding_newline() {
442        for (side, index) in [(Side::Before, 4), (Side::After, 9)] {
443            let edit = TableEdit::Insert {
444                anchor: "Intro".into(),
445                side,
446                rows: 2,
447                columns: 3,
448                match_case: true,
449            };
450            let (request, preview) = resolve(&doc(fixture()), &edit).unwrap();
451            assert_eq!(preview.location.index, index);
452            assert!(preview.preceding_newline);
453            assert_eq!(
454                (
455                    preview.rows_before,
456                    preview.columns_before,
457                    preview.rows_after,
458                    preview.columns_after
459                ),
460                (0, 0, 2, 3)
461            );
462            assert_eq!(
463                serde_json::to_value(BatchUpdateDocumentRequest::new(request, "revision")).unwrap(),
464                json!({"requests":[{"insertTable":{"rows":2,"columns":3,"location":{"index":index,"tabId":"child"}}}],"writeControl":{"requiredRevisionId":"revision"}})
465            );
466        }
467    }
468
469    #[test]
470    fn all_dimension_wire_shapes_and_effects_are_exact() {
471        for (verb, key, extra, dimensions) in [
472            (
473                TableVerb::InsertRow,
474                "insertTableRow",
475                Some("insertBelow"),
476                (3, 2),
477            ),
478            (
479                TableVerb::InsertColumn,
480                "insertTableColumn",
481                Some("insertRight"),
482                (2, 3),
483            ),
484            (TableVerb::DeleteRow, "deleteTableRow", None, (1, 2)),
485            (TableVerb::DeleteColumn, "deleteTableColumn", None, (2, 1)),
486        ] {
487            for after in [false, true] {
488                let edit = TableEdit::Dimension {
489                    verb,
490                    cell: "Alpha".into(),
491                    after,
492                    match_case: true,
493                };
494                let (request, preview) = resolve(&doc(fixture()), &edit).unwrap();
495                assert_eq!((preview.rows_after, preview.columns_after), dimensions);
496                assert_eq!(
497                    (preview.row_index, preview.column_index),
498                    (Some(0), Some(0))
499                );
500                let wire =
501                    serde_json::to_value(BatchUpdateDocumentRequest::new(request, "rev")).unwrap();
502                let mut expected = json!({"tableCellLocation":{"tableStartLocation":{"index":10,"tabId":"child"},"rowIndex":0,"columnIndex":0}});
503                if let Some(field) = extra {
504                    expected[field] = json!(after);
505                }
506                assert_eq!(wire["requests"], json!([{key:expected}]));
507                assert_eq!(wire["writeControl"], json!({"requiredRevisionId":"rev"}));
508            }
509        }
510    }
511
512    #[test]
513    fn refuses_merged_nested_suggested_and_invalid_tables() {
514        for (kind, expected) in [
515            (0, TableError::UnsupportedTable),
516            (1, TableError::UnsupportedTable),
517            (2, TableError::SuggestedTable),
518            (3, TableError::InvalidTable),
519            (4, TableError::SuggestedTable),
520            (5, TableError::UnsupportedTable),
521        ] {
522            let mut value = fixture();
523            let t = table(&mut value);
524            match kind {
525                0 => t["tableRows"][0]["tableCells"][0]["tableCellStyle"]["rowSpan"] = json!(2),
526                1 => t["tableRows"][1]["tableCells"][1]["content"][0]["table"] = json!({}),
527                2 => t["tableRows"][1]["suggestedDeletionIds"] = json!(["suggestion"]),
528                3 => t["tableRows"][0]["tableCells"][0]["endIndex"] = json!(1000),
529                4 => {
530                    t["tableRows"][1]["tableCells"][1]["content"][0]["paragraph"]["elements"][0]
531                        ["textRun"]["suggestedInsertionIds"] = json!(["s"]);
532                }
533                _ => {
534                    t["tableRows"][1]["tableCells"]
535                        .as_array_mut()
536                        .unwrap()
537                        .pop();
538                }
539            }
540            assert_eq!(
541                resolve(&doc(value), &dimension(TableVerb::DeleteRow)).unwrap_err(),
542                expected,
543                "kind {kind}"
544            );
545        }
546    }
547
548    #[test]
549    fn refuses_last_dimension_and_body_cell_boundary_confusion() {
550        let mut value = fixture();
551        let t = table(&mut value);
552        t["rows"] = json!(1);
553        t["tableRows"].as_array_mut().unwrap().pop();
554        assert_eq!(
555            resolve(&doc(value), &dimension(TableVerb::DeleteRow)).unwrap_err(),
556            TableError::LastDimension
557        );
558        let edit = TableEdit::Insert {
559            anchor: "Alpha".into(),
560            side: Side::After,
561            rows: 1,
562            columns: 1,
563            match_case: true,
564        };
565        assert_eq!(
566            resolve(&doc(fixture()), &edit).unwrap_err(),
567            TableError::UnsafeBoundary
568        );
569        let edit = TableEdit::Dimension {
570            verb: TableVerb::InsertRow,
571            cell: "Intro".into(),
572            after: false,
573            match_case: true,
574        };
575        assert_eq!(
576            resolve(&doc(fixture()), &edit).unwrap_err(),
577            TableError::UnsafeBoundary
578        );
579        let mut value = fixture();
580        let t = table(&mut value);
581        t["columns"] = json!(1);
582        for row in t["tableRows"].as_array_mut().unwrap() {
583            row["tableCells"].as_array_mut().unwrap().pop();
584        }
585        assert_eq!(
586            resolve(&doc(value), &dimension(TableVerb::DeleteColumn)).unwrap_err(),
587            TableError::LastDimension
588        );
589    }
590
591    #[test]
592    fn invalid_missing_ambiguous_and_suggested_anchors_fail_closed() {
593        for (needle, expected) in [
594            ("", AnchorError::InvalidAnchor),
595            ("missing", AnchorError::NotFound),
596            ("a", AnchorError::Ambiguous { count: 5 }),
597        ] {
598            let edit = TableEdit::Dimension {
599                verb: TableVerb::InsertRow,
600                cell: needle.into(),
601                after: false,
602                match_case: true,
603            };
604            let error = resolve(&doc(fixture()), &edit).unwrap_err();
605            if needle.is_empty() {
606                assert_eq!(error, TableError::InvalidIntent);
607            } else {
608                assert_eq!(error, TableError::Anchor { error: expected });
609            }
610        }
611        let mut value = fixture();
612        table(&mut value)["tableRows"][0]["tableCells"][0]["content"][0]["paragraph"]["elements"]
613            [0]["textRun"]["suggestedDeletionIds"] = json!(["s"]);
614        assert_eq!(
615            resolve(&doc(value), &dimension(TableVerb::InsertRow)).unwrap_err(),
616            TableError::Anchor {
617                error: AnchorError::SuggestedContent
618            }
619        );
620    }
621
622    fn insert(anchor: &str, rows: i64, columns: i64) -> TableEdit {
623        TableEdit::Insert {
624            anchor: anchor.into(),
625            side: Side::After,
626            rows,
627            columns,
628            match_case: true,
629        }
630    }
631
632    /// Each verb's CLI, audit-log and permission spelling is pinned, so a
633    /// transposed arm cannot send a delete through the additive gate.
634    #[test]
635    fn every_verb_has_its_own_label_log_name_and_permission() {
636        for (verb, label, log, gate) in [
637            (
638                TableVerb::InsertTable,
639                "insert-table",
640                "docs-insert-table",
641                DriveOperation::DocsStructure,
642            ),
643            (
644                TableVerb::InsertRow,
645                "insert-table-row",
646                "docs-insert-table-row",
647                DriveOperation::DocsStructure,
648            ),
649            (
650                TableVerb::InsertColumn,
651                "insert-table-column",
652                "docs-insert-table-column",
653                DriveOperation::DocsStructure,
654            ),
655            (
656                TableVerb::DeleteRow,
657                "delete-table-row",
658                "docs-delete-table-row",
659                DriveOperation::DocsTableDelete,
660            ),
661            (
662                TableVerb::DeleteColumn,
663                "delete-table-column",
664                "docs-delete-table-column",
665                DriveOperation::DocsTableDelete,
666            ),
667        ] {
668            assert_eq!(verb.label(), label);
669            assert_eq!(verb.log_operation(), log);
670            assert_eq!(verb.gate_operation(), gate);
671        }
672    }
673
674    #[test]
675    fn an_edit_reports_the_verb_it_describes() {
676        assert_eq!(insert("Intro", 1, 1).verb(), TableVerb::InsertTable);
677        for verb in [TableVerb::InsertRow, TableVerb::DeleteColumn] {
678            assert_eq!(dimension(verb).verb(), verb);
679        }
680    }
681
682    /// The allocation bound is on the product, checked without overflow, and
683    /// a rejected intent never reads the document.
684    #[test]
685    fn insertion_refuses_non_positive_oversized_and_overflowing_grids() {
686        for (rows, columns) in [
687            (0, 1),
688            (1, 0),
689            (-1, 5),
690            (5, -1),
691            (1, 10_001),
692            (10_001, 1),
693            (101, 100),
694            (i64::MAX, 2),
695        ] {
696            assert_eq!(
697                resolve(&doc(fixture()), &insert("Intro", rows, columns)).unwrap_err(),
698                TableError::InvalidIntent,
699                "{rows}x{columns}"
700            );
701        }
702        for (rows, columns) in [(1, 1), (1, 10_000), (10_000, 1), (100, 100)] {
703            assert!(
704                resolve(&doc(fixture()), &insert("Intro", rows, columns)).is_ok(),
705                "{rows}x{columns}"
706            );
707        }
708    }
709
710    #[test]
711    fn insertion_and_dimension_edits_refuse_an_anchor_that_spans_paragraphs() {
712        for anchor in ["a\nb", "a\rb", ""] {
713            assert_eq!(
714                resolve(&doc(fixture()), &insert(anchor, 1, 1)).unwrap_err(),
715                TableError::InvalidIntent,
716                "{anchor:?}"
717            );
718            let edit = TableEdit::Dimension {
719                verb: TableVerb::DeleteRow,
720                cell: anchor.into(),
721                after: false,
722                match_case: true,
723            };
724            assert_eq!(
725                resolve(&doc(fixture()), &edit).unwrap_err(),
726                TableError::InvalidIntent,
727                "{anchor:?}"
728            );
729        }
730    }
731
732    /// `InsertTable` needs dimensions and a body anchor, which a `Dimension`
733    /// edit cannot carry, so it is refused before the document is read.
734    #[test]
735    fn a_dimension_edit_cannot_insert_a_table() {
736        assert_eq!(
737            resolve(&doc(fixture()), &dimension(TableVerb::InsertTable)).unwrap_err(),
738            TableError::InvalidIntent
739        );
740    }
741
742    /// Two structural elements claiming the anchor's index leave nothing to
743    /// pick between, so the edit is refused rather than guessing a container.
744    #[test]
745    fn an_anchor_inside_overlapping_body_elements_is_an_unsafe_boundary() {
746        let mut value = fixture();
747        value["tabs"][0]["documentTab"]["body"]["content"][1]["startIndex"] = json!(1);
748        assert_eq!(
749            resolve(&doc(value), &insert("Intro", 1, 1)).unwrap_err(),
750            TableError::UnsafeBoundary
751        );
752    }
753
754    /// The table's declared shape and each row, cell and paragraph's indices
755    /// must tile the table exactly; any gap, overlap or overrun fails closed.
756    #[test]
757    fn inconsistent_table_dimensions_and_index_ranges_fail_closed() {
758        type Corrupt = fn(&mut serde_json::Value);
759        let cases: [(&str, Corrupt); 8] = [
760            ("declared rows disagree with the row list", |value| {
761                table(value)["rows"] = json!(3);
762            }),
763            ("no declared rows", |value| {
764                table(value)["rows"] = json!(0);
765            }),
766            ("no declared columns", |value| {
767                table(value)["columns"] = json!(0);
768            }),
769            ("row overruns the table", |value| {
770                table(value)["tableRows"][0]["endIndex"] = json!(1000);
771            }),
772            ("row has no end", |value| {
773                table(value)["tableRows"][0]
774                    .as_object_mut()
775                    .unwrap()
776                    .remove("endIndex");
777            }),
778            ("cell has no start", |value| {
779                table(value)["tableRows"][1]["tableCells"][1]
780                    .as_object_mut()
781                    .unwrap()
782                    .remove("startIndex");
783            }),
784            (
785                "cell's first paragraph does not follow its start",
786                |value| {
787                    table(value)["tableRows"][1]["tableCells"][1]["startIndex"] = json!(37);
788                },
789            ),
790            ("cell's paragraphs stop short of its end", |value| {
791                value["tabs"][0]["documentTab"]["body"]["content"][1]["endIndex"] = json!(44);
792                table(value)["tableRows"][1]["endIndex"] = json!(44);
793                table(value)["tableRows"][1]["tableCells"][1]["endIndex"] = json!(44);
794            }),
795        ];
796        for (name, corrupt) in cases {
797            let mut value = fixture();
798            corrupt(&mut value);
799            assert_eq!(
800                resolve(&doc(value), &dimension(TableVerb::DeleteRow)).unwrap_err(),
801                TableError::InvalidTable,
802                "{name}"
803            );
804        }
805    }
806
807    #[test]
808    fn unmodelled_inline_suggestions_and_bad_cell_paragraph_bounds_fail_closed() {
809        let mut value = fixture();
810        table(&mut value)["tableRows"][1]["tableCells"][1]["content"][0]["paragraph"]
811            ["suggestedParagraphStyleChanges"] = json!({"s": {}});
812        let document = doc(value);
813        assert_eq!(
814            resolve(&document, &dimension(TableVerb::DeleteColumn)).unwrap_err(),
815            TableError::SuggestedTable
816        );
817        let mut value = fixture();
818        table(&mut value)["tableRows"][0]["tableCells"][0]["startIndex"] = json!(1);
819        assert_eq!(
820            resolve(&doc(value), &dimension(TableVerb::DeleteColumn)).unwrap_err(),
821            TableError::InvalidTable
822        );
823        let mut value = fixture();
824        value["tabs"][0]["tabProperties"]["tabId"] = json!("");
825        assert_eq!(
826            resolve(&doc(value), &dimension(TableVerb::InsertRow)).unwrap_err(),
827            TableError::Anchor {
828                error: AnchorError::InvalidIndices
829            }
830        );
831    }
832}