Skip to main content

omni_dev/drive/sheets/
cell_format.rs

1//! `drive sheets read-cell-format` engine — reports a range's per-cell
2//! `userEnteredFormat`, note and data-validation rule (issue #1878).
3//!
4//! Exists to close [ADR-0083](../../../docs/adrs/adr-0083.md) §5's
5//! gate-moving question in tooling rather than by eye: "a verb live-verified
6//! to move or write formatting resolves both `sheets-write` and
7//! `sheets-structure`" is an obligation the grid-mutation tranche has and,
8//! until this verb existed, no tooling to discharge — nothing under
9//! `omni-dev drive sheets` read a cell's format back.
10//!
11//! Read-only, so — like `read.rs` — it consults no write gate. See that
12//! module's doc comment for why: [ADR-0071](../../../docs/adrs/adr-0071.md)
13//! §11 records read-path gate enforcement as a deliberate, still-open
14//! fast-follow across the whole Drive surface, not something this command
15//! opts out of on its own.
16
17use anyhow::Result;
18use serde::Serialize;
19
20use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
21use crate::drive::sheets::a1;
22use crate::drive::sheets::api::SheetsApi;
23use crate::drive::sheets::format::format_hex_color;
24use crate::drive::sheets::grid_range;
25use crate::drive::sheets::types::{
26    BordersSnapshot, CellFormatSnapshot, ColorStyleSnapshot, DataValidationRule, GridData,
27};
28
29/// Refuse a range whose cell count is over this budget.
30///
31/// An `includeGridData`-shaped response grows with the range requested, not
32/// with how much of it is actually populated, so the narrow `fields` mask
33/// alone does not bound the cost the way it does for `sheets read`'s
34/// values-only fetch. Errors rather than truncating — the same posture as
35/// `read.rs::MAX_SHEETS_PER_READ`.
36///
37/// Enforced in two layers: [`check_cell_budget`] refuses a **bounded**
38/// range (`A1:D10`) before any HTTP call; [`check_response_cell_budget`] is
39/// the backstop for an open-ended one (`A:A`, `5:20`) that layer can't
40/// compute up front — it can't avoid the request's own cost, but it does
41/// stop an oversized result from being processed and printed.
42const MAX_CELLS_PER_READ_CELL_FORMAT: i64 = 50_000;
43
44/// Per-call options.
45#[derive(Debug, Clone)]
46pub struct ReadCellFormatOptions {
47    /// Spreadsheet id.
48    pub spreadsheet_id: String,
49    /// A sheet title, supplying a prefix for a bare `range`.
50    pub sheet: Option<String>,
51    /// The A1 range to read, which may carry its own `Sheet!` prefix.
52    /// Required — a whole-workbook read is out of scope for v1 (see the
53    /// module doc comment).
54    pub range: String,
55}
56
57/// One non-default cell's formatting, note and/or data validation rule.
58///
59/// A cell present in the response but carrying none of the three — the
60/// common case for most of a real range — is never turned into an entry;
61/// see [`entries_from_grid`].
62#[derive(Debug, Clone, Serialize, PartialEq)]
63pub struct CellFormatEntry {
64    /// The cell's absolute A1 address within its sheet (e.g. `"B3"`).
65    pub cell: String,
66    /// The cell's user-entered format, when it carries a non-default one.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub format: Option<CellFormatSnapshot>,
69    /// The cell's note, when it has one.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub note: Option<String>,
72    /// The cell's data validation rule, when it has one.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub data_validation: Option<DataValidationRule>,
75}
76
77/// One sheet's non-default cells within the requested range.
78#[derive(Debug, Clone, Serialize, PartialEq)]
79pub struct SheetCellFormats {
80    /// The sheet title, when it could be determined.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub title: Option<String>,
83    /// The composed range that was read.
84    pub range: String,
85    /// Every non-default cell, in row-major order.
86    pub cells: Vec<CellFormatEntry>,
87}
88
89/// The result of a `read-cell-format` call.
90#[derive(Debug, Clone, Serialize, PartialEq)]
91pub struct CellFormatOutcome {
92    /// The spreadsheet that was read.
93    pub spreadsheet_id: String,
94    /// One entry — v1 always reads exactly one range on one sheet; the
95    /// `Vec` shape mirrors [`crate::drive::sheets::read::ReadOutcome`] so a
96    /// future whole-sheet/whole-workbook mode could extend this without a
97    /// breaking output-shape change.
98    pub sheets: Vec<SheetCellFormats>,
99}
100
101impl JsonlSerialize for CellFormatOutcome {
102    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
103        write_scalar_jsonl(self, out)
104    }
105}
106
107/// Reads a range's cell-level formatting.
108pub async fn read_cell_formats(
109    api: &SheetsApi<'_>,
110    opts: &ReadCellFormatOptions,
111) -> Result<CellFormatOutcome> {
112    anyhow::ensure!(
113        !opts.range.trim().is_empty(),
114        "--range must not be empty; a whole-workbook read is out of scope for read-cell-format"
115    );
116    let composed = a1::compose(opts.sheet.as_deref(), Some(opts.range.as_str()))?;
117    check_cell_budget(&composed)?;
118
119    let workbook = api
120        .get_cell_formats(&opts.spreadsheet_id, &composed)
121        .await?;
122
123    // `sheets.data` is scoped by the `ranges` query parameter to just the
124    // targeted sheet (see `get_cell_formats`'s doc comment), so the first
125    // sheet carrying any chunk at all is always the one that was read —
126    // the same reasoning `pivot.rs::cell_snapshot_at` relies on.
127    let sheet = workbook.sheets.iter().find(|sheet| !sheet.data.is_empty());
128    let (title, cells) = if let Some(sheet) = sheet {
129        check_response_cell_budget(&sheet.data)?;
130        (
131            Some(sheet.title().to_string()),
132            sheet.data.iter().flat_map(entries_from_grid).collect(),
133        )
134    } else {
135        (opts.sheet.clone(), Vec::new())
136    };
137
138    Ok(CellFormatOutcome {
139        spreadsheet_id: opts.spreadsheet_id.clone(),
140        sheets: vec![SheetCellFormats {
141            title,
142            range: composed,
143            cells,
144        }],
145    })
146}
147
148/// Refuses a bounded range over [`MAX_CELLS_PER_READ_CELL_FORMAT`] cells,
149/// before any HTTP call. `composed` may carry a `Sheet!` prefix; only the
150/// bare A1 portion is parsed. An unbounded or unparseable range (a defined
151/// name, say) is let through — this cap only ever refuses what it can
152/// actually compute, the same "never guess" stance `a1.rs` documents.
153fn check_cell_budget(composed: &str) -> Result<()> {
154    let bare = a1::split_sheet_prefix(composed).map_or(composed, |(_, rest)| rest);
155    let Ok(grid) = grid_range::parse_grid_range(0, bare) else {
156        return Ok(());
157    };
158    let Some(cells) = grid_range::cell_count(&grid) else {
159        return Ok(());
160    };
161    anyhow::ensure!(
162        cells <= MAX_CELLS_PER_READ_CELL_FORMAT,
163        "range covers {cells} cells, over the {MAX_CELLS_PER_READ_CELL_FORMAT}-cell budget for \
164         read-cell-format; narrow --range"
165    );
166    Ok(())
167}
168
169/// Refuses a response whose actual grid data covers more cells than
170/// [`MAX_CELLS_PER_READ_CELL_FORMAT`] — the backstop for an open-ended
171/// range [`check_cell_budget`] let through unchecked. Runs after the HTTP
172/// call, so it cannot avoid the request's own cost, but it stops an
173/// oversized result from being processed and printed, with the same
174/// message shape the pre-flight cap uses.
175fn check_response_cell_budget(data: &[GridData]) -> Result<()> {
176    let cells: i64 = data
177        .iter()
178        .map(|grid| {
179            let width = grid
180                .row_data
181                .iter()
182                .map(|row| row.values.len() as i64)
183                .max()
184                .unwrap_or(0);
185            grid.row_data.len() as i64 * width
186        })
187        .sum();
188    anyhow::ensure!(
189        cells <= MAX_CELLS_PER_READ_CELL_FORMAT,
190        "response covers {cells} cells, over the {MAX_CELLS_PER_READ_CELL_FORMAT}-cell budget \
191         for read-cell-format; narrow --range"
192    );
193    Ok(())
194}
195
196/// Extracts every non-default cell from one grid-data chunk, as absolute A1
197/// addresses within the sheet — `start_row`/`start_column` are the chunk's
198/// own offset (the API indexes a read relative to the range it was asked
199/// for, not the sheet), the same convention
200/// `grid_range::non_blank_locations` documents.
201fn entries_from_grid(grid: &GridData) -> Vec<CellFormatEntry> {
202    let start_row = grid.start_row.unwrap_or(0);
203    let start_column = grid.start_column.unwrap_or(0);
204    let mut entries = Vec::new();
205    for (row_idx, row) in grid.row_data.iter().enumerate() {
206        for (col_idx, cell) in row.values.iter().enumerate() {
207            if cell.user_entered_format.is_none()
208                && cell.note.is_none()
209                && cell.data_validation.is_none()
210            {
211                continue;
212            }
213            let cell_address = format!(
214                "{}{}",
215                grid_range::column_index_to_letters(start_column + col_idx as i64),
216                start_row + row_idx as i64 + 1
217            );
218            entries.push(CellFormatEntry {
219                cell: cell_address,
220                format: cell.user_entered_format.clone(),
221                note: cell.note.clone(),
222                data_validation: cell.data_validation.clone(),
223            });
224        }
225    }
226    entries
227}
228
229/// Renders one color, `#RRGGBB` for the `rgbColor` arm or `theme:<NAME>` for
230/// the `themeColor` arm — never guessing black for a theme color the way
231/// defaulting an absent `rgb_color` would.
232fn render_color_style(style: &ColorStyleSnapshot) -> String {
233    match (style.rgb_color, style.theme_color.as_deref()) {
234        (Some(color), _) => format_hex_color(color),
235        (None, Some(theme)) => format!("theme:{theme}"),
236        (None, None) => "?".to_string(),
237    }
238}
239
240/// Renders which of the four outer edges carry a border, comma-joined
241/// (e.g. `"top,left"`) — the edge names, not each one's style or color,
242/// which stay in the full structured (`-o json`/`-o yaml`) output.
243fn render_border_edges(borders: &BordersSnapshot) -> String {
244    [
245        ("top", borders.top.is_some()),
246        ("bottom", borders.bottom.is_some()),
247        ("left", borders.left.is_some()),
248        ("right", borders.right.is_some()),
249    ]
250    .into_iter()
251    .filter_map(|(name, set)| set.then_some(name))
252    .collect::<Vec<_>>()
253    .join(",")
254}
255
256/// Renders one entry as a compact, diff-friendly table line — the
257/// before/after-`diff` workflow ADR-0083 §5's verification is built around.
258/// Note *content* is never shown here (only its presence) — the same
259/// "counts and locations, never contents" stance ADR-0083 §6 documents for
260/// `non_blank_locations`; the full text is available via `-o json`/`-o yaml`.
261pub(crate) fn render_line(entry: &CellFormatEntry) -> String {
262    let mut attrs: Vec<String> = Vec::new();
263    if let Some(format) = &entry.format {
264        if let Some(bg) = &format.background_color_style {
265            attrs.push(format!("bg={}", render_color_style(bg)));
266        }
267        if let Some(text) = &format.text_format {
268            if text.bold == Some(true) {
269                attrs.push("bold".to_string());
270            }
271            if text.italic == Some(true) {
272                attrs.push("italic".to_string());
273            }
274            if text.strikethrough == Some(true) {
275                attrs.push("strikethrough".to_string());
276            }
277            if text.underline == Some(true) {
278                attrs.push("underline".to_string());
279            }
280            if let Some(fg) = &text.foreground_color_style {
281                attrs.push(format!("fg={}", render_color_style(fg)));
282            }
283        }
284        if let Some(number) = &format.number_format {
285            if !number.format_type.is_empty() {
286                if number.pattern.is_empty() {
287                    attrs.push(format!("number={}", number.format_type));
288                } else {
289                    attrs.push(format!(
290                        "number={}:{:?}",
291                        number.format_type, number.pattern
292                    ));
293                }
294            } // omni-dev: coverage ignore-line reason="this closing brace reports 0 hits under llvm-cov regardless of test count — verified locally: render_line_covers_background_bold_number_note_and_validation and render_line_covers_italic_strikethrough_underline_foreground_alignment_and_bare_validation both complete the block above (the pattern and no-pattern pushes both measure as hit), yet this specific brace, closing the format_type.is_empty() check, never registers a hit; the same llvm-cov region-attribution artifact as src/utils/settings.rs:1096"
295        }
296        if let Some(align) = &format.horizontal_alignment {
297            attrs.push(format!("align={align}"));
298        }
299        if let Some(borders) = &format.borders {
300            if !borders.is_empty() {
301                attrs.push(format!("border={}", render_border_edges(borders)));
302            }
303        }
304    }
305    if entry.note.is_some() {
306        attrs.push("note".to_string());
307    }
308    if let Some(validation) = &entry.data_validation {
309        if validation.condition.condition_type.is_empty() {
310            attrs.push("validation".to_string());
311        } else {
312            attrs.push(format!(
313                "validation={}",
314                validation.condition.condition_type
315            ));
316        }
317    }
318    if attrs.is_empty() {
319        entry.cell.clone()
320    } else {
321        format!("{}  {}", entry.cell, attrs.join(" "))
322    }
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used)]
327mod tests {
328    use super::*;
329    use crate::drive::sheets::types::{
330        BooleanCondition, BorderSnapshot, CellSnapshot, Color, NumberFormat, RowData,
331        TextFormatSnapshot,
332    };
333
334    // ── JsonlSerialize ─────────────────────────────────────────────────
335
336    #[test]
337    fn write_jsonl_emits_the_outcome_as_one_line() {
338        let outcome = CellFormatOutcome {
339            spreadsheet_id: "sheet-1".to_string(),
340            sheets: vec![SheetCellFormats {
341                title: Some("Q1".to_string()),
342                range: "A1:B2".to_string(),
343                cells: Vec::new(),
344            }],
345        };
346        let mut buf = Vec::new();
347        outcome.write_jsonl(&mut buf).unwrap();
348        let text = String::from_utf8(buf).unwrap();
349        assert_eq!(text.lines().count(), 1, "{text}");
350        assert!(text.contains("sheet-1"), "{text}");
351    }
352
353    // ── check_cell_budget ──────────────────────────────────────────────
354
355    #[test]
356    fn check_cell_budget_allows_a_range_within_the_cap() {
357        assert!(check_cell_budget("A1:D10").is_ok());
358        assert!(check_cell_budget("'My Sheet'!A1:D10").is_ok());
359    }
360
361    #[test]
362    fn check_cell_budget_refuses_a_bounded_range_over_the_cap() {
363        // 1,000 rows * 1,000 cols = 1,000,000 cells, well over the cap.
364        let err = check_cell_budget("A1:ALL1000").unwrap_err();
365        assert!(err.to_string().contains("cell budget"), "{err}");
366    }
367
368    #[test]
369    fn check_cell_budget_lets_an_unbounded_range_through() {
370        assert!(check_cell_budget("A:A").is_ok());
371        assert!(check_cell_budget("5:20").is_ok());
372        assert!(check_cell_budget("A5:A").is_ok());
373    }
374
375    #[test]
376    fn check_cell_budget_lets_an_unparseable_range_through() {
377        // A defined name, or anything else `parse_grid_range` refuses —
378        // the server is authoritative, per `a1.rs`'s stance.
379        assert!(check_cell_budget("MyNamedRange").is_ok());
380    }
381
382    #[test]
383    fn check_cell_budget_lets_a_range_parse_never_finishes_through() {
384        // "@@" fails `parse_grid_range` outright (not even a bare column
385        // reference, unlike an all-letters defined name) — the actual
386        // `Err` arm of the `let-else`, distinct from the case above where
387        // parsing succeeds but the resulting column range is unbounded.
388        assert!(check_cell_budget("@@").is_ok());
389    }
390
391    // ── entries_from_grid ──────────────────────────────────────────────
392
393    fn cell_with_format() -> CellSnapshot {
394        CellSnapshot {
395            user_entered_format: Some(CellFormatSnapshot {
396                background_color_style: Some(ColorStyleSnapshot {
397                    rgb_color: Some(Color {
398                        red: 1.0,
399                        green: 0.0,
400                        blue: 0.0,
401                    }),
402                    theme_color: None,
403                }),
404                ..Default::default()
405            }),
406            ..Default::default()
407        }
408    }
409
410    #[test]
411    fn entries_from_grid_skips_a_cell_carrying_no_format_note_or_validation() {
412        let grid = GridData {
413            start_row: Some(0),
414            start_column: Some(0),
415            row_data: vec![RowData {
416                values: vec![CellSnapshot::default()],
417            }],
418        };
419        assert_eq!(entries_from_grid(&grid), Vec::new());
420    }
421
422    #[test]
423    fn entries_from_grid_addresses_cells_absolutely_from_a_non_zero_offset() {
424        // Offset past Z, to exercise the AA rollover too.
425        let grid = GridData {
426            start_row: Some(4),
427            start_column: Some(26),
428            row_data: vec![RowData {
429                values: vec![CellSnapshot::default(), cell_with_format()],
430            }],
431        };
432        let entries = entries_from_grid(&grid);
433        assert_eq!(entries.len(), 1);
434        assert_eq!(entries[0].cell, "AB5");
435    }
436
437    #[test]
438    fn entries_from_grid_handles_ragged_rows() {
439        let grid = GridData {
440            start_row: Some(0),
441            start_column: Some(0),
442            row_data: vec![
443                RowData { values: vec![] },
444                RowData {
445                    values: vec![cell_with_format()],
446                },
447            ],
448        };
449        let entries = entries_from_grid(&grid);
450        assert_eq!(entries.len(), 1);
451        assert_eq!(entries[0].cell, "A2");
452    }
453
454    #[test]
455    fn entries_from_grid_includes_a_note_only_cell() {
456        let grid = GridData {
457            start_row: Some(0),
458            start_column: Some(0),
459            row_data: vec![RowData {
460                values: vec![CellSnapshot {
461                    note: Some("hello".to_string()),
462                    ..Default::default()
463                }],
464            }],
465        };
466        let entries = entries_from_grid(&grid);
467        assert_eq!(entries.len(), 1);
468        assert_eq!(entries[0].note.as_deref(), Some("hello"));
469        assert!(entries[0].format.is_none());
470    }
471
472    #[test]
473    fn entries_from_grid_includes_a_validation_only_cell() {
474        let grid = GridData {
475            start_row: Some(0),
476            start_column: Some(0),
477            row_data: vec![RowData {
478                values: vec![CellSnapshot {
479                    data_validation: Some(DataValidationRule {
480                        condition: BooleanCondition {
481                            condition_type: "BOOLEAN".to_string(),
482                            values: vec![],
483                        },
484                        ..Default::default()
485                    }),
486                    ..Default::default()
487                }],
488            }],
489        };
490        let entries = entries_from_grid(&grid);
491        assert_eq!(entries.len(), 1);
492        assert!(entries[0].data_validation.is_some());
493    }
494
495    // ── render_color_style ─────────────────────────────────────────────
496
497    #[test]
498    fn render_color_style_renders_black_with_every_channel_omitted() {
499        let style = ColorStyleSnapshot {
500            rgb_color: Some(Color::default()),
501            theme_color: None,
502        };
503        assert_eq!(render_color_style(&style), "#000000");
504    }
505
506    #[test]
507    fn render_color_style_renders_a_partial_channel() {
508        let style = ColorStyleSnapshot {
509            rgb_color: Some(Color {
510                red: 1.0,
511                ..Default::default()
512            }),
513            theme_color: None,
514        };
515        assert_eq!(render_color_style(&style), "#FF0000");
516    }
517
518    #[test]
519    fn render_color_style_renders_a_theme_color_by_name_never_as_black() {
520        let style = ColorStyleSnapshot {
521            rgb_color: None,
522            theme_color: Some("TEXT".to_string()),
523        };
524        assert_eq!(render_color_style(&style), "theme:TEXT");
525    }
526
527    #[test]
528    fn render_color_style_renders_a_question_mark_when_neither_channel_is_set() {
529        let style = ColorStyleSnapshot {
530            rgb_color: None,
531            theme_color: None,
532        };
533        assert_eq!(render_color_style(&style), "?");
534    }
535
536    // ── render_line ────────────────────────────────────────────────────
537
538    #[test]
539    fn render_line_covers_background_bold_number_note_and_validation() {
540        let entry = CellFormatEntry {
541            cell: "B3".to_string(),
542            format: Some(CellFormatSnapshot {
543                background_color_style: Some(ColorStyleSnapshot {
544                    rgb_color: Some(Color {
545                        red: 1.0,
546                        ..Default::default()
547                    }),
548                    theme_color: None,
549                }),
550                text_format: Some(TextFormatSnapshot {
551                    bold: Some(true),
552                    ..Default::default()
553                }),
554                number_format: Some(NumberFormat {
555                    format_type: "CURRENCY".to_string(),
556                    pattern: "$#,##0".to_string(),
557                }),
558                horizontal_alignment: None,
559                borders: Some(BordersSnapshot {
560                    top: Some(BorderSnapshot::default()),
561                    left: Some(BorderSnapshot::default()),
562                    bottom: None,
563                    right: None,
564                }),
565            }),
566            note: Some("a note".to_string()),
567            data_validation: Some(DataValidationRule {
568                condition: BooleanCondition {
569                    condition_type: "ONE_OF_LIST".to_string(),
570                    values: vec![],
571                },
572                ..Default::default()
573            }),
574        };
575        let line = render_line(&entry);
576        assert!(line.starts_with("B3  "), "{line}");
577        assert!(line.contains("bg=#FF0000"), "{line}");
578        assert!(line.contains("bold"), "{line}");
579        assert!(line.contains("border=top,left"), "{line}");
580        assert!(line.contains("number=CURRENCY:\"$#,##0\""), "{line}");
581        assert!(line.contains("note"), "{line}");
582        assert!(line.contains("validation=ONE_OF_LIST"), "{line}");
583        // Note content is never shown — only its presence.
584        assert!(!line.contains("a note"), "{line}");
585    }
586
587    #[test]
588    fn render_line_with_nothing_set_is_bare_cell_address() {
589        let entry = CellFormatEntry {
590            cell: "A1".to_string(),
591            format: None,
592            note: None,
593            data_validation: None,
594        };
595        assert_eq!(render_line(&entry), "A1");
596    }
597
598    #[test]
599    fn render_line_covers_italic_strikethrough_underline_foreground_alignment_and_bare_validation()
600    {
601        let entry = CellFormatEntry {
602            cell: "C4".to_string(),
603            format: Some(CellFormatSnapshot {
604                background_color_style: None,
605                text_format: Some(TextFormatSnapshot {
606                    bold: None,
607                    italic: Some(true),
608                    strikethrough: Some(true),
609                    underline: Some(true),
610                    foreground_color_style: Some(ColorStyleSnapshot {
611                        rgb_color: None,
612                        theme_color: Some("TEXT".to_string()),
613                    }),
614                }),
615                number_format: Some(NumberFormat {
616                    format_type: "PERCENT".to_string(),
617                    pattern: String::new(),
618                }),
619                horizontal_alignment: Some("CENTER".to_string()),
620                borders: None,
621            }),
622            note: None,
623            data_validation: Some(DataValidationRule {
624                condition: BooleanCondition {
625                    condition_type: String::new(),
626                    values: vec![],
627                },
628                ..Default::default()
629            }),
630        };
631        let line = render_line(&entry);
632        let tokens: Vec<&str> = line.split_whitespace().collect();
633        assert!(tokens.contains(&"italic"), "{line}");
634        assert!(tokens.contains(&"strikethrough"), "{line}");
635        assert!(tokens.contains(&"underline"), "{line}");
636        assert!(tokens.contains(&"fg=theme:TEXT"), "{line}");
637        // No pattern set: the bare `number=<type>` form, never `type:pattern`.
638        assert!(tokens.contains(&"number=PERCENT"), "{line}");
639        assert!(tokens.contains(&"align=CENTER"), "{line}");
640        // An empty condition type: the bare `validation` form, never
641        // `validation=<type>`.
642        assert!(tokens.contains(&"validation"), "{line}");
643    }
644
645    #[test]
646    fn render_line_omits_an_empty_borders_object() {
647        // A cell whose `borders` key is present but every edge is absent
648        // must not print a bare `border=`.
649        let entry = CellFormatEntry {
650            cell: "A1".to_string(),
651            format: Some(CellFormatSnapshot {
652                borders: Some(BordersSnapshot::default()),
653                ..Default::default()
654            }),
655            note: None,
656            data_validation: None,
657        };
658        assert_eq!(render_line(&entry), "A1");
659    }
660
661    // ── render_border_edges ────────────────────────────────────────────
662
663    #[test]
664    fn render_border_edges_lists_only_the_set_edges_in_a_fixed_order() {
665        let borders = BordersSnapshot {
666            top: None,
667            bottom: Some(BorderSnapshot::default()),
668            left: None,
669            right: Some(BorderSnapshot::default()),
670        };
671        assert_eq!(render_border_edges(&borders), "bottom,right");
672    }
673
674    // ── check_response_cell_budget ─────────────────────────────────────
675
676    #[test]
677    fn check_response_cell_budget_allows_a_response_within_the_cap() {
678        let data = vec![GridData {
679            start_row: Some(0),
680            start_column: Some(0),
681            row_data: vec![RowData {
682                values: vec![CellSnapshot::default(); 10],
683            }],
684        }];
685        assert!(check_response_cell_budget(&data).is_ok());
686    }
687
688    #[test]
689    fn check_response_cell_budget_refuses_an_oversized_response() {
690        // This is exactly the backstop `check_cell_budget` can't provide
691        // for an unbounded range: a huge actual response, caught here.
692        let data = vec![GridData {
693            start_row: Some(0),
694            start_column: Some(0),
695            row_data: vec![
696                RowData {
697                    values: vec![CellSnapshot::default(); 1000],
698                };
699                1000
700            ],
701        }];
702        let err = check_response_cell_budget(&data).unwrap_err();
703        assert!(err.to_string().contains("cell budget"), "{err}");
704    }
705}