1use 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
29const MAX_CELLS_PER_READ_CELL_FORMAT: i64 = 50_000;
43
44#[derive(Debug, Clone)]
46pub struct ReadCellFormatOptions {
47 pub spreadsheet_id: String,
49 pub sheet: Option<String>,
51 pub range: String,
55}
56
57#[derive(Debug, Clone, Serialize, PartialEq)]
63pub struct CellFormatEntry {
64 pub cell: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub format: Option<CellFormatSnapshot>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub note: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub data_validation: Option<DataValidationRule>,
75}
76
77#[derive(Debug, Clone, Serialize, PartialEq)]
79pub struct SheetCellFormats {
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub title: Option<String>,
83 pub range: String,
85 pub cells: Vec<CellFormatEntry>,
87}
88
89#[derive(Debug, Clone, Serialize, PartialEq)]
91pub struct CellFormatOutcome {
92 pub spreadsheet_id: String,
94 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
107pub 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 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
148fn 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
169fn 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
196fn 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
229fn 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
240fn 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
256pub(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 } }
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 #[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 #[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 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 assert!(check_cell_budget("MyNamedRange").is_ok());
380 }
381
382 #[test]
383 fn check_cell_budget_lets_a_range_parse_never_finishes_through() {
384 assert!(check_cell_budget("@@").is_ok());
389 }
390
391 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 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 #[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 #[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 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 assert!(tokens.contains(&"number=PERCENT"), "{line}");
639 assert!(tokens.contains(&"align=CENTER"), "{line}");
640 assert!(tokens.contains(&"validation"), "{line}");
643 }
644
645 #[test]
646 fn render_line_omits_an_empty_borders_object() {
647 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 #[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 #[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 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}