Skip to main content

omni_dev/drive/docs/
named_range.rs

1//! Bounded named-range mutations resolved against the revision-controlled snapshot.
2
3use serde::Serialize;
4
5use super::anchor::insertion_counts;
6use super::types::{Document, Range, StructuralElement};
7use super::write_types::{
8    CreateNamedRangeRequest, DocsRequest, NamedRangeIdRequest, ReplaceNamedRangeContentRequest,
9    TabsCriteria,
10};
11
12/// Explicit scope: absent tab is legacy-only; absent segment is the body.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Scope {
15    /// A server tab ID, or legacy single-body scope.
16    pub tab_id: Option<String>,
17    /// Header/footer/footnote ID, or body scope.
18    pub segment_id: Option<String>,
19}
20
21/// A typed operation with no name-based fan-out selector.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Mutation {
24    /// Create metadata over a bounded UTF-16 span.
25    Create {
26        /// Label, 1–256 UTF-16 units.
27        name: String,
28        /// Inclusive start in the snapshot.
29        start_index: i64,
30        /// Exclusive end in the snapshot.
31        end_index: i64,
32    },
33    /// Remove only the label and its span metadata.
34    Delete {
35        /// Stable named-range ID.
36        id: String,
37    },
38    /// Replace one plain-text span; empty text removes its content.
39    Replace {
40        /// Stable named-range ID.
41        id: String,
42        /// Replacement text.
43        text: String,
44    },
45}
46
47impl Mutation {
48    /// Pure validation before any network read.
49    pub(crate) fn validate(&self, scope: &Scope) -> Result<(), String> {
50        if scope.tab_id.as_ref().is_some_and(String::is_empty)
51            || scope.segment_id.as_ref().is_some_and(String::is_empty)
52        {
53            return Err("scope IDs cannot be empty".into());
54        }
55        match self {
56            Self::Create {
57                name,
58                start_index,
59                end_index,
60            } => {
61                if !(1..=256).contains(&name.encode_utf16().count()) {
62                    return Err("named-range name must be 1–256 UTF-16 units".into());
63                }
64                if *start_index < 0 || end_index <= start_index {
65                    return Err("named-range indices must describe a nonempty bounded span".into());
66                }
67            }
68            Self::Delete { id } | Self::Replace { id, .. } if id.is_empty() => {
69                return Err("named-range ID cannot be empty".into());
70            }
71            Self::Replace { text, .. } if !text.is_empty() && insertion_counts(text).0 == 0 => {
72                return Err("replacement becomes empty after Docs strips unsupported characters; use explicit empty text for deletion".into());
73            }
74            _ => {}
75        }
76        Ok(())
77    }
78}
79
80/// Refusal metadata containing no document prose.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum Error {
84    /// No unique tab/segment matching the explicit scope.
85    InvalidScope,
86    /// The ID is absent or occurs more than once within the selected tab.
87    UnresolvedId,
88    /// Some spans escape the selected tab or segment.
89    ScopeMismatch,
90    /// Replacement would remove additional discontinuous spans.
91    DiscontinuousRange,
92    /// Indices do not identify safe contiguous plain text within one paragraph.
93    UnsafeRange,
94    /// The selected text has pending content suggestions.
95    SuggestedContent,
96}
97
98/// Distinguishes removing a label from removing its text in structured output.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "kebab-case")]
101pub enum Effect {
102    /// Adds only named-range metadata.
103    CreateMetadata,
104    /// Removes only named-range metadata.
105    DeleteMetadata,
106    /// Replaces existing text with nonempty content.
107    ReplaceContent,
108    /// Removes content with an explicitly empty replacement.
109    DeleteContent,
110}
111
112/// Metadata-only description of the complete selected effect.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct Preview {
115    /// Explicit metadata/content distinction, independent of the generic status.
116    pub effect: Effect,
117    /// Stable ID; unknown before a create reply.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub named_range_id: Option<String>,
120    /// Complete span set; never elided in structured output.
121    pub ranges: Vec<Range>,
122    /// Unicode scalars whose content is removed by replacement.
123    pub removed_chars: usize,
124    /// UTF-8 bytes whose content is removed by replacement.
125    pub removed_bytes: usize,
126    /// Unicode scalars inserted by replacement.
127    pub inserted_chars: usize,
128    /// UTF-8 bytes inserted by replacement.
129    pub inserted_bytes: usize,
130}
131
132/// Build the wire request and preview together from the same inline snapshot.
133pub(crate) fn resolve(
134    document: &Document,
135    scope: &Scope,
136    mutation: &Mutation,
137) -> Result<(DocsRequest, Preview), Error> {
138    let tabs = document.resolved_tabs();
139    // Modern responses must have unique, nonempty identities; never silently
140    // reinterpret a missing tab ID as the API's first-tab default.
141    let mut ids = std::collections::HashSet::new();
142    if !document.tabs.is_empty()
143        && tabs
144            .iter()
145            .any(|tab| tab.tab_id.is_none_or(|id| id.is_empty() || !ids.insert(id)))
146    {
147        return Err(Error::InvalidScope);
148    }
149    let selected: Vec<_> = tabs
150        .iter()
151        .filter(|tab| tab.tab_id == scope.tab_id.as_deref())
152        .collect();
153    let [tab] = selected.as_slice() else {
154        return Err(Error::InvalidScope);
155    };
156    let content = if let Some(segment_id) = scope.segment_id.as_deref() {
157        let segments: Vec<_> = tab
158            .headers()
159            .into_iter()
160            .chain(tab.footers())
161            .chain(tab.footnotes())
162            .filter(|segment| segment.segment_id == segment_id)
163            .collect();
164        let [segment] = segments.as_slice() else {
165            return Err(Error::InvalidScope);
166        };
167        segment.content
168    } else {
169        &tab.body.ok_or(Error::InvalidScope)?.content
170    };
171    let mut preview = Preview {
172        effect: match mutation {
173            Mutation::Create { .. } => Effect::CreateMetadata,
174            Mutation::Delete { .. } => Effect::DeleteMetadata,
175            Mutation::Replace { text, .. } if text.is_empty() => Effect::DeleteContent,
176            Mutation::Replace { .. } => Effect::ReplaceContent,
177        },
178        named_range_id: None,
179        ranges: vec![],
180        removed_chars: 0,
181        removed_bytes: 0,
182        inserted_chars: 0,
183        inserted_bytes: 0,
184    };
185    if let Mutation::Create {
186        name,
187        start_index,
188        end_index,
189    } = mutation
190    {
191        validate_text_range(
192            content,
193            *start_index,
194            *end_index,
195            scope.segment_id.is_some(),
196        )?;
197        let range = Range {
198            start_index: Some(*start_index),
199            end_index: Some(*end_index),
200            tab_id: scope.tab_id.clone(),
201            segment_id: scope.segment_id.clone(),
202        };
203        preview.ranges.push(range.clone());
204        return Ok((
205            DocsRequest::CreateNamedRange(CreateNamedRangeRequest {
206                name: name.clone(),
207                range,
208            }),
209            preview,
210        ));
211    }
212    let id = match mutation {
213        Mutation::Delete { id } | Mutation::Replace { id, .. } => id,
214        Mutation::Create { .. } => return Err(Error::UnsafeRange), // omni-dev: coverage ignore-line reason="the Create branch above always returns, so a Create mutation never reaches the ID lookup; the arm exists solely for exhaustiveness"
215    };
216    let matches: Vec<_> = tab
217        .named_ranges
218        .values()
219        .flat_map(|group| &group.named_ranges)
220        .filter(|range| range.named_range_id.as_deref() == Some(id))
221        .collect();
222    let [named] = matches.as_slice() else {
223        return Err(Error::UnresolvedId);
224    };
225    if named.ranges.is_empty() {
226        return Err(Error::UnsafeRange);
227    }
228    for range in &named.ranges {
229        // With includeTabsContent, a range's missing tabId inherits its
230        // DocumentTab owner. An explicit conflicting ID is never accepted.
231        if range
232            .tab_id
233            .as_deref()
234            .filter(|id| !id.is_empty())
235            .is_some_and(|id| Some(id) != tab.tab_id)
236            || range.segment_id.as_deref().filter(|id| !id.is_empty())
237                != scope.segment_id.as_deref()
238        {
239            return Err(Error::ScopeMismatch);
240        }
241        let start = range.start_index.unwrap_or(0); // proto3 omits zero
242        let end = range.end_index.ok_or(Error::UnsafeRange)?;
243        if start < 0 || end <= start {
244            return Err(Error::UnsafeRange);
245        }
246        preview.ranges.push(Range {
247            tab_id: scope.tab_id.clone(),
248            segment_id: scope.segment_id.clone(),
249            start_index: Some(start),
250            end_index: Some(end),
251        });
252    }
253    preview.named_range_id = Some(id.clone());
254    let target = NamedRangeIdRequest {
255        named_range_id: id.clone(),
256        tabs_criteria: scope.tab_id.as_ref().map(|id| TabsCriteria {
257            tab_ids: [id.clone()],
258        }),
259    };
260    match mutation {
261        Mutation::Delete { .. } => Ok((DocsRequest::DeleteNamedRange(target), preview)),
262        Mutation::Replace { text, .. } => {
263            let [range] = preview.ranges.as_slice() else {
264                return Err(Error::DiscontinuousRange);
265            };
266            let (chars, bytes) = validate_text_range(
267                content,
268                range.start_index.ok_or(Error::UnsafeRange)?,
269                range.end_index.ok_or(Error::UnsafeRange)?,
270                scope.segment_id.is_some(),
271            )?;
272            preview.removed_chars = chars;
273            preview.removed_bytes = bytes;
274            (preview.inserted_chars, preview.inserted_bytes) = insertion_counts(text);
275            Ok((
276                DocsRequest::ReplaceNamedRangeContent(ReplaceNamedRangeContentRequest {
277                    target,
278                    text: text.clone(),
279                }),
280                preview,
281            ))
282        }
283        Mutation::Create { .. } => Err(Error::UnsafeRange), // omni-dev: coverage ignore-line reason="the Create branch above always returns, so a Create mutation never reaches this match; the arm exists solely for exhaustiveness"
284    }
285}
286
287// Deliberately narrower than anchor deletion: a named replacement must not
288// merge paragraphs or remove non-text objects. Traverse tables only to find
289// an individual cell paragraph; generated TOCs remain excluded.
290fn validate_text_range(
291    elements: &[StructuralElement],
292    start: i64,
293    end: i64,
294    non_body: bool,
295) -> Result<(usize, usize), Error> {
296    if start < i64::from(!non_body) || end <= start {
297        return Err(Error::UnsafeRange);
298    }
299    let mut candidates = Vec::new();
300    matching_paragraphs(elements, start, end, &mut candidates);
301    let [element] = candidates.as_slice() else {
302        return Err(Error::UnsafeRange);
303    };
304    let paragraph = element.paragraph.as_ref().ok_or(Error::UnsafeRange)?;
305    let p_start = element.start_index.unwrap_or(0);
306    let p_end = element.end_index.ok_or(Error::UnsafeRange)?;
307    if p_start < i64::from(!non_body) || p_end <= p_start {
308        return Err(Error::UnsafeRange);
309    }
310    let mut previous = p_start;
311    let mut cursor = start;
312    let mut chars = 0;
313    let mut bytes = 0;
314    for inline in &paragraph.elements {
315        let lo = inline.start_index.unwrap_or(0);
316        let hi = inline.end_index.ok_or(Error::UnsafeRange)?;
317        if lo < previous || hi <= lo || hi > p_end {
318            return Err(Error::UnsafeRange);
319        }
320        previous = hi;
321        let Some(run) = &inline.text_run else {
322            if lo < end && hi > start {
323                return Err(Error::UnsafeRange);
324            }
325            continue;
326        };
327        if lo.checked_add(
328            i64::try_from(run.content.encode_utf16().count()).map_err(|_| Error::UnsafeRange)?,
329        ) != Some(hi)
330        {
331            return Err(Error::UnsafeRange);
332        }
333        if hi <= start || lo >= end {
334            continue;
335        }
336        if lo > cursor {
337            return Err(Error::UnsafeRange);
338        }
339        if !run.suggested_insertion_ids.is_empty() || !run.suggested_deletion_ids.is_empty() {
340            return Err(Error::SuggestedContent);
341        }
342        let mut index = lo;
343        for ch in run.content.chars() {
344            let next = index + ch.len_utf16() as i64;
345            if index < end && next > start {
346                if index < start || next > end || ch == '\n' {
347                    return Err(Error::UnsafeRange);
348                }
349                chars += 1;
350                bytes += ch.len_utf8();
351            }
352            index = next;
353        }
354        cursor = hi.min(end);
355    }
356    if cursor == end
357        && paragraph
358            .elements
359            .last()
360            .and_then(|e| e.text_run.as_ref())
361            .is_some_and(|run| previous == p_end && run.content.ends_with('\n'))
362    {
363        return Ok((chars, bytes));
364    }
365    Err(Error::UnsafeRange)
366}
367
368fn matching_paragraphs<'a>(
369    elements: &'a [StructuralElement],
370    start: i64,
371    end: i64,
372    out: &mut Vec<&'a StructuralElement>,
373) {
374    for element in elements {
375        if element.paragraph.is_some()
376            && element.start_index.unwrap_or(0) <= start
377            && element.end_index.is_some_and(|p_end| end < p_end)
378        {
379            out.push(element);
380        }
381        if let Some(table) = &element.table {
382            for row in &table.table_rows {
383                for cell in &row.table_cells {
384                    matching_paragraphs(&cell.content, start, end, out);
385                }
386            }
387        }
388    }
389}
390
391impl Preview {
392    /// Bounded human rendering; structured outcomes retain the complete span list.
393    pub(crate) fn describe_spans(&self) -> String {
394        let mut spans = self
395            .ranges
396            .iter()
397            .take(50)
398            .map(|range| {
399                format!(
400                    "[{}, {}) UTF-16, tab {}, segment {}",
401                    range.start_index.unwrap_or(0),
402                    range.end_index.unwrap_or(0),
403                    range.tab_id.as_deref().unwrap_or("legacy"),
404                    range.segment_id.as_deref().unwrap_or("body")
405                )
406            })
407            .collect::<Vec<_>>()
408            .join("; ");
409        if self.ranges.len() > 50 {
410            spans.push_str(&format!("; {} more span(s)", self.ranges.len() - 50));
411        }
412        spans
413    }
414}
415
416#[cfg(test)]
417#[allow(clippy::unwrap_used, clippy::expect_used)]
418mod tests {
419    use super::*;
420    use serde_json::{json, Value};
421
422    fn paragraph(start: i64, text: &str) -> Value {
423        let end = start + text.encode_utf16().count() as i64;
424        json!({"startIndex": start, "endIndex": end, "paragraph": {"elements": [
425            {"startIndex": start, "endIndex": end, "textRun": {"content": text}}
426        ]}})
427    }
428
429    fn fixture(segment: Option<&str>, spans: Vec<Value>) -> (Document, Scope) {
430        let mut tab = json!({"body": {"content": [paragraph(1, "😀 label\n")]},
431        "namedRanges": {"label": {"name": "label", "namedRanges": [
432            {"namedRangeId": "nr", "name": "label", "ranges": spans}
433        ]}}});
434        if let Some(segment) = segment {
435            tab["headers"] = json!({segment: {"content": [paragraph(0, "😀 label\n")]}});
436        }
437        let document = serde_json::from_value(json!({"tabs": [{"tabProperties": {"tabId": "parent"},
438            "documentTab": {}, "childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": tab}]}]})).unwrap();
439        (
440            document,
441            Scope {
442                tab_id: Some("child".into()),
443                segment_id: segment.map(str::to_owned),
444            },
445        )
446    }
447
448    fn replace() -> Mutation {
449        Mutation::Replace {
450            id: "nr".into(),
451            text: "😀".into(),
452        }
453    }
454
455    #[test]
456    fn nested_tab_and_segment_wire_shapes_are_bounded_and_leased() {
457        for segment in [None, Some("h")] {
458            let start = i64::from(segment.is_none());
459            let (document, scope) = fixture(
460                segment,
461                vec![json!({"startIndex": start, "endIndex": start + 2, "segmentId": segment})],
462            );
463            for mutation in [
464                Mutation::Create {
465                    name: "label".into(),
466                    start_index: start,
467                    end_index: start + 2,
468                },
469                Mutation::Delete { id: "nr".into() },
470                replace(),
471            ] {
472                let (request, preview) = resolve(&document, &scope, &mutation).unwrap();
473                let wire = serde_json::to_value(
474                    super::super::write_types::BatchUpdateDocumentRequest::new(request, "r"),
475                )
476                .unwrap();
477                assert_eq!(wire["writeControl"], json!({"requiredRevisionId": "r"}));
478                assert_eq!(wire["requests"].as_array().unwrap().len(), 1);
479                assert_eq!(preview.ranges[0].tab_id.as_deref(), Some("child"));
480                match mutation {
481                    Mutation::Create { .. } => {
482                        assert_eq!(
483                            wire["requests"][0]["createNamedRange"]["range"]["tabId"],
484                            "child"
485                        );
486                        assert_eq!(
487                            wire["requests"][0]["createNamedRange"]["range"]["segmentId"],
488                            json!(segment)
489                        );
490                    }
491                    Mutation::Delete { .. } | Mutation::Replace { .. } => {
492                        let op = if matches!(mutation, Mutation::Delete { .. }) {
493                            "deleteNamedRange"
494                        } else {
495                            "replaceNamedRangeContent"
496                        };
497                        assert_eq!(wire["requests"][0][op]["namedRangeId"], "nr");
498                        assert_eq!(
499                            wire["requests"][0][op]["tabsCriteria"],
500                            json!({"tabIds": ["child"]})
501                        );
502                        assert!(wire["requests"][0][op].get("name").is_none());
503                        assert!(wire["requests"][0][op].get("namedRangeName").is_none());
504                    }
505                }
506            }
507            let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
508            assert_eq!(
509                (
510                    preview.removed_chars,
511                    preview.removed_bytes,
512                    preview.inserted_chars,
513                    preview.inserted_bytes
514                ),
515                (1, 4, 1, 4)
516            );
517        }
518    }
519
520    #[test]
521    fn name_limit_is_utf16_and_stripped_text_cannot_hide_deletion() {
522        let scope = Scope {
523            tab_id: None,
524            segment_id: None,
525        };
526        for (name, valid) in [
527            (String::new(), false),
528            ("😀".repeat(128), true),
529            ("😀".repeat(129), false),
530        ] {
531            assert_eq!(
532                Mutation::Create {
533                    name,
534                    start_index: 1,
535                    end_index: 2
536                }
537                .validate(&scope)
538                .is_ok(),
539                valid
540            );
541        }
542        assert!(Mutation::Replace {
543            id: "nr".into(),
544            text: "\0\u{e000}".into()
545        }
546        .validate(&scope)
547        .is_err());
548        assert!(Mutation::Replace {
549            id: "nr".into(),
550            text: String::new()
551        }
552        .validate(&scope)
553        .is_ok());
554        assert!(Mutation::Delete { id: String::new() }
555            .validate(&scope)
556            .is_err());
557    }
558
559    #[test]
560    fn missing_duplicate_or_conflicting_scope_and_ids_are_refused() {
561        let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
562        for bad in [
563            Scope {
564                tab_id: None,
565                segment_id: None,
566            },
567            Scope {
568                tab_id: Some("missing".into()),
569                segment_id: None,
570            },
571            Scope {
572                tab_id: Some("child".into()),
573                segment_id: Some("missing".into()),
574            },
575        ] {
576            assert_eq!(
577                resolve(&document, &bad, &replace()),
578                Err(Error::InvalidScope)
579            );
580        }
581        let mut duplicate = document.clone();
582        duplicate.tabs.push(document.tabs[0].clone());
583        assert_eq!(
584            resolve(&duplicate, &scope, &replace()),
585            Err(Error::InvalidScope)
586        );
587        let mut no_identity = document.clone();
588        no_identity.tabs[0].tab_properties.as_mut().unwrap().tab_id = None;
589        assert_eq!(
590            resolve(&no_identity, &scope, &replace()),
591            Err(Error::InvalidScope)
592        );
593        let mut duplicate = document.clone();
594        let groups = &mut duplicate.tabs[0].child_tabs[0]
595            .document_tab
596            .as_mut()
597            .unwrap()
598            .named_ranges;
599        groups.insert("duplicate".into(), groups["label"].clone());
600        assert_eq!(
601            resolve(&duplicate, &scope, &replace()),
602            Err(Error::UnresolvedId)
603        );
604        assert_eq!(
605            resolve(
606                &document,
607                &scope,
608                &Mutation::Delete {
609                    id: "absent".into()
610                }
611            ),
612            Err(Error::UnresolvedId)
613        );
614        for span in [
615            json!({"startIndex": 1, "endIndex": 3, "tabId": "parent"}),
616            json!({"startIndex": 1, "endIndex": 3, "segmentId": "h"}),
617        ] {
618            let (document, scope) = fixture(None, vec![span]);
619            assert_eq!(
620                resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }),
621                Err(Error::ScopeMismatch)
622            );
623        }
624    }
625
626    #[test]
627    fn discontinuous_metadata_deletion_is_not_content_deletion() {
628        let (document, scope) = fixture(
629            None,
630            vec![
631                json!({"startIndex": 1, "endIndex": 3}),
632                json!({"startIndex": 4, "endIndex": 9}),
633            ],
634        );
635        assert_eq!(
636            resolve(&document, &scope, &replace()),
637            Err(Error::DiscontinuousRange)
638        );
639        let (request, preview) =
640            resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }).unwrap();
641        assert!(matches!(request, DocsRequest::DeleteNamedRange(_)));
642        assert_eq!(preview.ranges.len(), 2);
643        assert_eq!(preview.removed_chars, 0);
644    }
645
646    #[test]
647    fn numeric_spans_reject_surrogate_splits_newlines_and_invalid_bounds() {
648        for (start, end) in [(1, 2), (2, 3), (0, 3), (1, 10), (1, 20), (5, 4)] {
649            let (document, scope) =
650                fixture(None, vec![json!({"startIndex": start, "endIndex": end})]);
651            assert_eq!(
652                resolve(&document, &scope, &replace()),
653                Err(Error::UnsafeRange),
654                "{start}..{end}"
655            );
656        }
657        let (document, scope) = fixture(None, vec![json!({"startIndex": 1})]);
658        assert_eq!(
659            resolve(&document, &scope, &replace()),
660            Err(Error::UnsafeRange)
661        );
662    }
663
664    #[test]
665    fn split_runs_keep_indices_but_objects_suggestions_and_gaps_are_refused() {
666        for kind in ["split", "object", "gap", "suggested", "bad-index", "toc"] {
667            let (mut document, scope) =
668                fixture(None, vec![json!({"startIndex": 4, "endIndex": 9})]);
669            let body = document.tabs[0].child_tabs[0]
670                .document_tab
671                .as_mut()
672                .unwrap()
673                .body
674                .as_mut()
675                .unwrap();
676            let first = json!({"startIndex": 1, "endIndex": 6, "textRun": {"content": "😀 la"}});
677            let mut second =
678                json!({"startIndex": 6, "endIndex": 10, "textRun": {"content": "bel\n"}});
679            match kind {
680                "suggested" => second["textRun"]["suggestedInsertionIds"] = json!(["s"]),
681                "gap" => second["startIndex"] = json!(7),
682                "bad-index" => second["endIndex"] = json!(11),
683                "object" => {
684                    second = json!({"startIndex": 6, "endIndex": 10, "inlineObjectElement": {"inlineObjectId": "o"}});
685                }
686                _ => {}
687            }
688            body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [first, second]}}])).unwrap();
689            if kind == "toc" {
690                body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 10, "tableOfContents": {"content": [paragraph(1, "😀 label\n")]}}])).unwrap();
691            }
692            match kind {
693                "split" => assert!(resolve(&document, &scope, &replace()).is_ok()),
694                "suggested" => assert_eq!(
695                    resolve(&document, &scope, &replace()),
696                    Err(Error::SuggestedContent)
697                ),
698                _ => assert_eq!(
699                    resolve(&document, &scope, &replace()),
700                    Err(Error::UnsafeRange)
701                ),
702            }
703        }
704    }
705
706    #[test]
707    fn legacy_scope_omits_tab_criteria_and_nonbody_proto3_zero_is_valid() {
708        let document: Document = serde_json::from_value(json!({"body": {"content": [paragraph(1, "abc\n")]},
709            "footnotes": {"f": {"content": [paragraph(0, "abc\n")]}},
710            "namedRanges": {"label": {"namedRanges": [{"namedRangeId": "nr", "ranges": [{"endIndex": 3, "segmentId": "f"}]}]}}})).unwrap();
711        let scope = Scope {
712            tab_id: None,
713            segment_id: Some("f".into()),
714        };
715        let (request, preview) = resolve(&document, &scope, &replace()).unwrap();
716        assert_eq!(preview.ranges[0].start_index, Some(0));
717        let wire = serde_json::to_value(request).unwrap();
718        assert!(wire["replaceNamedRangeContent"]
719            .get("tabsCriteria")
720            .is_none());
721    }
722    #[test]
723    fn table_cells_preserve_suggestion_refusals_and_overlapping_indices_fail_closed() {
724        let (mut document, scope) = fixture(None, vec![json!({"startIndex": 3, "endIndex": 5})]);
725        let mut p = paragraph(3, "😀 label\n");
726        let body = document.tabs[0].child_tabs[0]
727            .document_tab
728            .as_mut()
729            .unwrap()
730            .body
731            .as_mut()
732            .unwrap();
733        body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 14,
734            "table": {"tableRows": [{"tableCells": [{"content": [p.clone()]}]}]}}]))
735        .unwrap();
736        assert!(resolve(&document, &scope, &replace()).is_ok());
737        p["paragraph"]["elements"][0]["textRun"]["suggestedDeletionIds"] = json!(["suggestion"]);
738        document.tabs[0].child_tabs[0]
739            .document_tab
740            .as_mut()
741            .unwrap()
742            .body
743            .as_mut()
744            .unwrap()
745            .content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 14,
746                "table": {"tableRows": [{"tableCells": [{"content": [p.clone()]}]}]}}]))
747        .unwrap();
748        assert_eq!(
749            resolve(&document, &scope, &replace()),
750            Err(Error::SuggestedContent)
751        );
752        let mut plain = paragraph(3, "😀 label\n");
753        document.tabs[0].child_tabs[0]
754            .document_tab
755            .as_mut()
756            .unwrap()
757            .body
758            .as_mut()
759            .unwrap()
760            .content = serde_json::from_value(json!([plain.clone(), plain.clone()])).unwrap();
761        assert_eq!(
762            resolve(&document, &scope, &replace()),
763            Err(Error::UnsafeRange)
764        );
765        plain.as_object_mut().unwrap().remove("startIndex");
766        document.tabs[0].child_tabs[0]
767            .document_tab
768            .as_mut()
769            .unwrap()
770            .body
771            .as_mut()
772            .unwrap()
773            .content = serde_json::from_value(json!([plain])).unwrap();
774        assert_eq!(
775            resolve(&document, &scope, &replace()),
776            Err(Error::UnsafeRange)
777        );
778    }
779
780    /// Replaces the child tab's body with `content`, so a test states only the
781    /// paragraph shape it exercises.
782    fn set_body(document: &mut Document, content: Value) {
783        document.tabs[0].child_tabs[0]
784            .document_tab
785            .as_mut()
786            .unwrap()
787            .body
788            .as_mut()
789            .unwrap()
790            .content = serde_json::from_value(content).unwrap();
791    }
792
793    #[test]
794    fn empty_scope_ids_and_unbounded_create_spans_are_refused_before_any_read() {
795        let delete = Mutation::Delete { id: "nr".into() };
796        for scope in [
797            Scope {
798                tab_id: Some(String::new()),
799                segment_id: None,
800            },
801            Scope {
802                tab_id: None,
803                segment_id: Some(String::new()),
804            },
805        ] {
806            assert_eq!(
807                delete.validate(&scope).unwrap_err(),
808                "scope IDs cannot be empty"
809            );
810        }
811        let scope = Scope {
812            tab_id: None,
813            segment_id: None,
814        };
815        for (start_index, end_index) in [(-1, 2), (3, 3), (4, 3)] {
816            let create = Mutation::Create {
817                name: "label".into(),
818                start_index,
819                end_index,
820            };
821            assert_eq!(
822                create.validate(&scope).unwrap_err(),
823                "named-range indices must describe a nonempty bounded span",
824                "{start_index}..{end_index}"
825            );
826        }
827    }
828
829    #[test]
830    fn create_refuses_a_span_that_is_not_safe_plain_text() {
831        let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
832        // [1, 10) would swallow the paragraph's newline.
833        let create = Mutation::Create {
834            name: "label".into(),
835            start_index: 1,
836            end_index: 10,
837        };
838        assert_eq!(resolve(&document, &scope, &create), Err(Error::UnsafeRange));
839    }
840
841    #[test]
842    fn an_existing_range_with_no_spans_is_unsafe_to_mutate() {
843        let (document, scope) = fixture(None, vec![]);
844        for mutation in [Mutation::Delete { id: "nr".into() }, replace()] {
845            assert_eq!(
846                resolve(&document, &scope, &mutation),
847                Err(Error::UnsafeRange)
848            );
849        }
850    }
851
852    /// Runs and non-text elements wholly outside the span are skipped rather
853    /// than refused: only what the span touches has to be plain text.
854    #[test]
855    fn elements_outside_the_span_are_skipped() {
856        let (mut document, scope) = fixture(None, vec![json!({"startIndex": 3, "endIndex": 5})]);
857        set_body(
858            &mut document,
859            json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [
860                {"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "o"}},
861                {"startIndex": 2, "endIndex": 10, "textRun": {"content": "abcdefg\n"}},
862            ]}}]),
863        );
864        let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
865        assert_eq!((preview.removed_chars, preview.removed_bytes), (2, 2));
866
867        let (mut document, scope) = fixture(None, vec![json!({"startIndex": 4, "endIndex": 6})]);
868        set_body(
869            &mut document,
870            json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [
871                {"startIndex": 1, "endIndex": 3, "textRun": {"content": "ab"}},
872                {"startIndex": 3, "endIndex": 7, "textRun": {"content": "cdef"}},
873                {"startIndex": 7, "endIndex": 10, "textRun": {"content": "gh\n"}},
874            ]}}]),
875        );
876        let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
877        assert_eq!((preview.removed_chars, preview.removed_bytes), (2, 2));
878    }
879
880    #[test]
881    fn an_index_gap_inside_the_span_is_refused() {
882        let (mut document, scope) = fixture(None, vec![json!({"startIndex": 4, "endIndex": 9})]);
883        // Index 6 belongs to no run, though both runs are internally consistent.
884        set_body(
885            &mut document,
886            json!([{"startIndex": 1, "endIndex": 11, "paragraph": {"elements": [
887                {"startIndex": 1, "endIndex": 6, "textRun": {"content": "😀 la"}},
888                {"startIndex": 7, "endIndex": 11, "textRun": {"content": "bel\n"}},
889            ]}}]),
890        );
891        assert_eq!(
892            resolve(&document, &scope, &replace()),
893            Err(Error::UnsafeRange)
894        );
895    }
896
897    /// The span must end inside a paragraph whose final run is its
898    /// newline-terminated end, or the replacement could join paragraphs.
899    #[test]
900    fn a_paragraph_that_does_not_end_in_its_newline_run_is_refused() {
901        for (span_start, span_end, paragraph_end, content) in [
902            // The last run has no newline.
903            (1, 3, 10, "abcdefghi"),
904            // The runs stop short of the paragraph's end index.
905            (1, 3, 12, "abcdefgh\n"),
906            // No run reaches the span at all.
907            (6, 8, 12, "abcd\n"),
908        ] {
909            let (mut document, scope) = fixture(
910                None,
911                vec![json!({"startIndex": span_start, "endIndex": span_end})],
912            );
913            let run_end = 1 + content.encode_utf16().count();
914            set_body(
915                &mut document,
916                json!([{"startIndex": 1, "endIndex": paragraph_end, "paragraph": {"elements": [
917                    {"startIndex": 1, "endIndex": run_end, "textRun": {"content": content}},
918                ]}}]),
919            );
920            assert_eq!(
921                resolve(&document, &scope, &replace()),
922                Err(Error::UnsafeRange),
923                "{content:?} in {span_start}..{span_end}"
924            );
925        }
926    }
927
928    #[test]
929    fn effects_and_bounded_rendering_distinguish_metadata_from_content_deletion() {
930        let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
931        let (_, metadata) =
932            resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }).unwrap();
933        let (_, content) = resolve(
934            &document,
935            &scope,
936            &Mutation::Replace {
937                id: "nr".into(),
938                text: String::new(),
939            },
940        )
941        .unwrap();
942        assert_eq!(
943            serde_json::to_value(&metadata).unwrap()["effect"],
944            "delete-metadata"
945        );
946        assert_eq!(
947            serde_json::to_value(&content).unwrap()["effect"],
948            "delete-content"
949        );
950        assert!(metadata
951            .describe_spans()
952            .contains("[1, 3) UTF-16, tab child, segment body"));
953        let mut many = metadata;
954        many.ranges = vec![many.ranges[0].clone(); 51];
955        assert!(many.describe_spans().ends_with("1 more span(s)"));
956        assert_eq!(
957            serde_json::to_value(many).unwrap()["ranges"]
958                .as_array()
959                .unwrap()
960                .len(),
961            51
962        );
963    }
964}