1use crate::config::{OutputProfile, RecalcBackendKind, ServerConfig, TransportKind};
2use crate::model::{
3 CellSnapshot, CellValue, CellValueKind, CellValuePrimitive, DefineNameResponse,
4 DeleteNameResponse, FindValueMatch, FindValueResponse, GridCell, GridColumnHint, GridPayload,
5 GridRow, NamedRangesResponse, RangeValuesEntry, ReadTableResponse, RowSnapshot,
6 SheetOverviewResponse, SheetPageCompact, SheetPageFormat, SheetPageResponse, SheetPageValues,
7 StylePatch, TableOutputFormat, TableRow, UpdateNameResponse, Warning, WorkbookDescription,
8 WorkbookId,
9};
10use crate::styles::descriptor_from_style;
11use crate::workbook::{WorkbookContext, cell_to_value};
12use anyhow::{Context, Result, anyhow};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use umya_spreadsheet::{Spreadsheet, Worksheet};
20
21pub struct WorkbookSession {
26 spreadsheet: Spreadsheet,
27}
28
29impl WorkbookSession {
30 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
32 let workbook_bytes = bytes.as_ref();
33 let cursor = std::io::Cursor::new(workbook_bytes);
34 let spreadsheet = umya_spreadsheet::reader::xlsx::read_reader(cursor, true)
35 .context("failed to parse workbook bytes")?;
36 Ok(Self { spreadsheet })
37 }
38
39 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
41 let path = path.as_ref();
42 let bytes = fs::read(path)
43 .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
44 Self::from_bytes(bytes)
45 }
46
47 pub fn list_sheets(&self) -> Vec<String> {
49 self.spreadsheet
50 .get_sheet_collection()
51 .iter()
52 .map(|sheet| sheet.get_name().to_string())
53 .collect()
54 }
55
56 pub fn describe_workbook(&self) -> Result<WorkbookDescription> {
58 let workbook = self.as_workbook_context()?;
59 Ok(workbook.describe())
60 }
61
62 pub fn named_ranges(&self) -> Result<NamedRangesResponse> {
64 let workbook = self.as_workbook_context()?;
65 let items = workbook.named_items()?;
66 Ok(NamedRangesResponse {
67 workbook_id: workbook.id.clone(),
68 items,
69 })
70 }
71
72 pub fn define_name(
74 &mut self,
75 name: &str,
76 refers_to: &str,
77 scope: Option<&str>,
78 scope_sheet_name: Option<&str>,
79 ) -> Result<DefineNameResponse> {
80 use crate::model::{DefineNameResponse, NamedRangeScope};
81
82 let scope_kind = match scope {
83 Some("sheet") => NamedRangeScope::Sheet,
84 Some("workbook") | None => NamedRangeScope::Workbook,
85 Some(other) => {
86 return Err(anyhow!(
87 "invalid scope '{}': expected 'workbook' or 'sheet'",
88 other
89 ));
90 }
91 };
92 if scope_kind == NamedRangeScope::Sheet && scope_sheet_name.is_none() {
93 return Err(anyhow!(
94 "scope_sheet_name is required when scope is 'sheet'"
95 ));
96 }
97 if name.trim().is_empty() {
98 return Err(anyhow!("name must not be empty"));
99 }
100 if refers_to.trim().is_empty() {
101 return Err(anyhow!("refers_to must not be empty"));
102 }
103
104 let book = &mut self.spreadsheet;
105
106 match scope_kind {
107 NamedRangeScope::Sheet => {
108 let sn = scope_sheet_name.unwrap();
109 let sheet_index = resolve_sheet_index_on_spreadsheet(book, sn)?;
110 let sheet = book
111 .get_sheet_by_name_mut(sn)
112 .ok_or_else(|| anyhow!("sheet '{}' not found", sn))?;
113 sheet
114 .add_defined_name(name.to_string(), refers_to.to_string())
115 .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
116 let sheet = book
117 .get_sheet_by_name_mut(sn)
118 .ok_or_else(|| anyhow!("sheet disappeared"))?;
119 if let Some(last) = sheet.get_defined_names_mut().last_mut()
120 && last.get_name() == name
121 {
122 last.set_local_sheet_id(sheet_index);
123 }
124 }
125 NamedRangeScope::Workbook => {
126 let first_sheet: String = book
127 .get_sheet_collection()
128 .first()
129 .map(|s| s.get_name().to_string())
130 .ok_or_else(|| anyhow!("workbook has no sheets"))?;
131 let sheet = book
132 .get_sheet_by_name_mut(&first_sheet)
133 .ok_or_else(|| anyhow!("sheet not found"))?;
134 sheet
135 .add_defined_name(name.to_string(), refers_to.to_string())
136 .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
137 let sheet = book
138 .get_sheet_by_name_mut(&first_sheet)
139 .ok_or_else(|| anyhow!("sheet disappeared"))?;
140 let entry = sheet.get_defined_names_mut().pop();
141 if let Some(entry) = entry {
142 book.add_defined_names(entry);
143 }
144 }
145 }
146
147 Ok(DefineNameResponse {
148 workbook_id: WorkbookId("session".to_string()),
149 name: name.to_string(),
150 refers_to: refers_to.to_string(),
151 scope_kind,
152 scope_sheet_name: scope_sheet_name.map(|s| s.to_string()),
153 })
154 }
155
156 pub fn update_name(
158 &mut self,
159 name: &str,
160 refers_to: Option<&str>,
161 scope: Option<&str>,
162 scope_sheet_name: Option<&str>,
163 ) -> Result<UpdateNameResponse> {
164 use crate::model::{NamedRangeScope, UpdateNameResponse};
165
166 let scope_kind = match scope {
167 Some("sheet") => Some(NamedRangeScope::Sheet),
168 Some("workbook") => Some(NamedRangeScope::Workbook),
169 None => None,
170 Some(other) => {
171 return Err(anyhow!(
172 "invalid scope '{}': expected 'workbook' or 'sheet'",
173 other
174 ));
175 }
176 };
177 if name.trim().is_empty() {
178 return Err(anyhow!("name must not be empty"));
179 }
180
181 let book = &mut self.spreadsheet;
182 let mut found = false;
183 let mut previous_refers_to = String::new();
184 let mut effective_scope = NamedRangeScope::Workbook;
185 let mut effective_sheet: Option<String> = None;
186
187 if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
189 for defined in book.get_defined_names_mut().iter_mut() {
190 if defined.get_name() == name
191 && (scope_kind == Some(NamedRangeScope::Workbook)
192 || !defined.has_local_sheet_id())
193 {
194 previous_refers_to = defined.get_address();
195 if let Some(new_addr) = refers_to {
196 defined.set_address(new_addr.to_string());
197 }
198 effective_scope = NamedRangeScope::Workbook;
199 found = true;
200 break;
201 }
202 }
203 }
204
205 if !found && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
207 let sheet_names: Vec<String> = book
208 .get_sheet_collection()
209 .iter()
210 .map(|s| s.get_name().to_string())
211 .collect();
212 for sn in &sheet_names {
213 if let Some(filter) = scope_sheet_name
214 && !sn.eq_ignore_ascii_case(filter)
215 {
216 continue;
217 }
218 if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
219 for defined in sheet.get_defined_names_mut().iter_mut() {
220 if defined.get_name() == name {
221 previous_refers_to = defined.get_address();
222 if let Some(new_addr) = refers_to {
223 defined.set_address(new_addr.to_string());
224 }
225 effective_scope = NamedRangeScope::Sheet;
226 effective_sheet = Some(sn.clone());
227 found = true;
228 break;
229 }
230 }
231 }
232 if found {
233 break;
234 }
235 }
236 }
237
238 if !found {
239 return Err(anyhow!("named range '{}' not found", name));
240 }
241
242 let final_refers_to = refers_to
243 .map(|s| s.to_string())
244 .unwrap_or_else(|| previous_refers_to.clone());
245
246 Ok(UpdateNameResponse {
247 workbook_id: WorkbookId("session".to_string()),
248 name: name.to_string(),
249 refers_to: final_refers_to,
250 scope_kind: effective_scope,
251 scope_sheet_name: effective_sheet.or_else(|| scope_sheet_name.map(|s| s.to_string())),
252 previous_refers_to: Some(previous_refers_to),
253 })
254 }
255
256 pub fn delete_name(
258 &mut self,
259 name: &str,
260 scope: Option<&str>,
261 scope_sheet_name: Option<&str>,
262 ) -> Result<DeleteNameResponse> {
263 use crate::model::{DeleteNameResponse, NamedRangeScope};
264
265 let scope_kind = match scope {
266 Some("sheet") => Some(NamedRangeScope::Sheet),
267 Some("workbook") => Some(NamedRangeScope::Workbook),
268 None => None,
269 Some(other) => {
270 return Err(anyhow!(
271 "invalid scope '{}': expected 'workbook' or 'sheet'",
272 other
273 ));
274 }
275 };
276 if name.trim().is_empty() {
277 return Err(anyhow!("name must not be empty"));
278 }
279
280 let book = &mut self.spreadsheet;
281 let mut deleted = false;
282
283 if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
285 let names = book.get_defined_names_mut();
286 let before_len = names.len();
287 names.retain(|d| d.get_name() != name);
288 if names.len() < before_len {
289 deleted = true;
290 }
291 }
292
293 if !deleted && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
295 let sheet_names: Vec<String> = book
296 .get_sheet_collection()
297 .iter()
298 .map(|s| s.get_name().to_string())
299 .collect();
300 for sn in &sheet_names {
301 if let Some(filter) = scope_sheet_name
302 && !sn.eq_ignore_ascii_case(filter)
303 {
304 continue;
305 }
306 if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
307 let names = sheet.get_defined_names_mut();
308 let before_len = names.len();
309 names.retain(|d| d.get_name() != name);
310 if names.len() < before_len {
311 deleted = true;
312 break;
313 }
314 }
315 }
316 }
317
318 if !deleted {
319 return Err(anyhow!("named range '{}' not found", name));
320 }
321
322 Ok(DeleteNameResponse {
323 workbook_id: WorkbookId("session".to_string()),
324 name: name.to_string(),
325 deleted: true,
326 })
327 }
328
329 pub fn sheet_overview(
331 &self,
332 params: SessionSheetOverviewParams,
333 ) -> Result<SheetOverviewResponse> {
334 let workbook = self.as_workbook_context()?;
335 let mut overview = workbook.sheet_overview(¶ms.sheet_name)?;
336
337 let max_regions = params.max_regions.unwrap_or(25).max(1);
338 let max_headers = params.max_headers.unwrap_or(50).max(1);
339 let include_headers = params.include_headers.unwrap_or(true);
340
341 let region_limit = if params.max_regions == Some(0) {
342 usize::MAX
343 } else {
344 max_regions as usize
345 };
346 let header_limit = if params.max_headers == Some(0) {
347 usize::MAX
348 } else {
349 max_headers as usize
350 };
351
352 let total_regions = overview.detected_regions.len() as u32;
353 let mut headers_truncated = false;
354
355 for region in &mut overview.detected_regions {
356 let header_count = region.header_count.max(region.headers.len() as u32);
357 region.header_count = header_count;
358 if !include_headers {
359 region.headers.clear();
360 } else if region.headers.len() > header_limit {
361 region.headers.truncate(header_limit);
362 }
363 region.headers_truncated = region.headers.len() as u32 != header_count;
364 headers_truncated |= region.headers_truncated;
365 }
366
367 let regions_truncated = if overview.detected_regions.len() > region_limit {
368 overview.detected_regions.truncate(region_limit);
369 true
370 } else {
371 false
372 };
373
374 overview.detected_region_count = total_regions;
375 overview.detected_regions_truncated = regions_truncated;
376
377 if regions_truncated {
378 overview.notes.push(format!(
379 "Detected regions truncated to {} ({} total).",
380 region_limit, total_regions
381 ));
382 }
383 if headers_truncated {
384 overview.notes.push(format!(
385 "Region headers truncated to {} columns.",
386 header_limit
387 ));
388 }
389
390 Ok(overview)
391 }
392
393 pub fn find_value(&self, params: SessionFindValueParams) -> Result<FindValueResponse> {
395 if params.query.trim().is_empty() {
396 return Err(anyhow!("query is required"));
397 }
398
399 let query = if params.case_sensitive {
400 params.query.clone()
401 } else {
402 params.query.to_ascii_lowercase()
403 };
404 let offset = params.offset.unwrap_or(0);
405 let limit = params.limit.max(1);
406
407 let sheet_names: Vec<String> = if let Some(sheet_name) = params.sheet_name.as_ref() {
408 vec![sheet_name.clone()]
409 } else {
410 self.list_sheets()
411 };
412
413 let mut seen = 0u32;
414 let mut matches = Vec::new();
415 let mut truncated = false;
416
417 'outer: for sheet_name in sheet_names {
418 let sheet = self.sheet_by_name_required(&sheet_name)?;
419 let max_row = sheet.get_highest_row().max(1);
420 let max_col = sheet.get_highest_column().max(1);
421
422 for row in 1..=max_row {
423 for col in 1..=max_col {
424 let Some(cell) = sheet.get_cell((col, row)) else {
425 continue;
426 };
427 let Some(value) = cell_to_value(cell) else {
428 continue;
429 };
430
431 let haystack = if params.case_sensitive {
432 cell_value_to_string(value.clone())
433 } else {
434 cell_value_to_string_lower(value.clone())
435 };
436 if !haystack.contains(&query) {
437 continue;
438 }
439
440 if seen < offset {
441 seen += 1;
442 continue;
443 }
444
445 if matches.len() >= limit as usize {
446 truncated = true;
447 break 'outer;
448 }
449
450 matches.push(FindValueMatch {
451 address: crate::utils::cell_address(col, row),
452 sheet_name: sheet_name.clone(),
453 value: Some(value),
454 row_context: None,
455 neighbors: None,
456 label_hit: None,
457 });
458 seen += 1;
459 }
460 }
461 }
462
463 Ok(FindValueResponse {
464 workbook_id: WorkbookId("session".to_string()),
465 match_count: matches.len() as u32,
466 matches,
467 next_offset: truncated.then_some(offset + limit),
468 })
469 }
470
471 pub fn read_table(&self, params: SessionReadTableParams) -> Result<ReadTableResponse> {
473 let sheet_name = if let Some(name) = params.sheet_name.clone() {
474 name
475 } else {
476 self.list_sheets()
477 .into_iter()
478 .next()
479 .ok_or_else(|| anyhow!("workbook has no sheets"))?
480 };
481 let sheet = self.sheet_by_name_required(&sheet_name)?;
482
483 let bounds = if let Some(range) = params.range.as_ref() {
484 parse_range_bounds(range)?
485 } else {
486 RangeBounds {
487 min_col: 1,
488 min_row: 1,
489 max_col: sheet.get_highest_column().max(1),
490 max_row: sheet.get_highest_row().max(1),
491 }
492 };
493
494 let include_headers = params.include_headers;
495 let include_types = params.include_types;
496 let format = params.format;
497 let offset = params.offset.unwrap_or(0) as usize;
498 let limit = params.limit.max(1) as usize;
499
500 let column_indices = if let Some(columns) = params.columns.as_ref() {
501 resolve_columns(Some(columns), bounds.max_col)?
502 .into_iter()
503 .filter(|col| *col >= bounds.min_col && *col <= bounds.max_col)
504 .collect::<Vec<_>>()
505 } else {
506 (bounds.min_col..=bounds.max_col).collect::<Vec<_>>()
507 };
508
509 if column_indices.is_empty() {
510 return Err(anyhow!("no columns selected for read_table"));
511 }
512
513 let header_row_idx = bounds.min_row;
514 let mut headers: Vec<String> = column_indices
515 .iter()
516 .map(|col| {
517 if include_headers {
518 sheet
519 .get_cell((*col, header_row_idx))
520 .and_then(cell_to_value)
521 .map(cell_value_to_string)
522 .filter(|s| !s.trim().is_empty())
523 .unwrap_or_else(|| crate::utils::column_number_to_name(*col))
524 } else {
525 crate::utils::column_number_to_name(*col)
526 }
527 })
528 .collect();
529 dedupe_headers_in_place(&mut headers);
530
531 let data_start_row = if include_headers {
532 header_row_idx.saturating_add(1)
533 } else {
534 bounds.min_row
535 };
536 let data_rows_count = if data_start_row > bounds.max_row {
537 0usize
538 } else {
539 (bounds.max_row - data_start_row + 1) as usize
540 };
541
542 let row_start = data_start_row.saturating_add(offset as u32);
543 let row_end_exclusive = row_start.saturating_add(limit as u32);
544
545 let mut json_rows: Vec<TableRow> = Vec::new();
546 let mut raw_rows: Vec<Vec<Option<CellValue>>> = Vec::new();
547 let mut values_rows: Vec<Vec<Option<CellValuePrimitive>>> = Vec::new();
548 let mut types_rows: Vec<Vec<Option<CellValueKind>>> = Vec::new();
549
550 let mut row_idx = row_start;
551 while row_idx <= bounds.max_row && row_idx < row_end_exclusive {
552 let mut json_row = BTreeMap::new();
553 let mut raw_row = Vec::new();
554 let mut values_row = Vec::new();
555 let mut types_row = Vec::new();
556
557 for (idx, col) in column_indices.iter().enumerate() {
558 let value = sheet.get_cell((*col, row_idx)).and_then(cell_to_value);
559 json_row.insert(headers[idx].clone(), value.clone());
560 raw_row.push(value.clone());
561 values_row.push(value.as_ref().and_then(cell_value_to_primitive));
562 types_row.push(value.as_ref().map(cell_value_kind));
563 }
564
565 json_rows.push(json_row);
566 raw_rows.push(raw_row);
567 values_rows.push(values_row);
568 types_rows.push(types_row);
569 row_idx = row_idx.saturating_add(1);
570 }
571
572 let next_offset = if offset + json_rows.len() < data_rows_count {
573 Some((offset + json_rows.len()) as u32)
574 } else {
575 None
576 };
577
578 let csv = if matches!(format, TableOutputFormat::Csv) {
579 Some(build_csv_payload(&headers, &raw_rows, include_headers))
580 } else {
581 None
582 };
583
584 Ok(ReadTableResponse {
585 workbook_id: WorkbookId("session".to_string()),
586 sheet_name,
587 table_name: None,
588 warnings: Vec::<Warning>::new(),
589 headers: if matches!(format, TableOutputFormat::Csv) {
590 Vec::new()
591 } else {
592 headers
593 },
594 rows: if matches!(format, TableOutputFormat::Json) {
595 json_rows
596 } else {
597 Vec::new()
598 },
599 values: if matches!(format, TableOutputFormat::Values) {
600 Some(values_rows)
601 } else {
602 None
603 },
604 types: if include_types {
605 Some(types_rows)
606 } else {
607 None
608 },
609 csv,
610 total_rows: data_rows_count as u32,
611 next_offset,
612 })
613 }
614
615 pub fn range_values(
617 &self,
618 sheet_name: &str,
619 ranges: impl Into<SessionRangeSelection>,
620 ) -> Result<Vec<RangeValuesEntry>> {
621 let sheet = self.sheet_by_name_required(sheet_name)?;
622 let ranges = ranges.into().into_vec();
623 if ranges.is_empty() {
624 return Err(anyhow!("at least one range is required"));
625 }
626
627 let mut out = Vec::with_capacity(ranges.len());
628 for range in ranges {
629 let bounds = parse_range_bounds(&range)?;
630 let mut rows = Vec::new();
631
632 for row in bounds.min_row..=bounds.max_row {
633 let mut row_values = Vec::new();
634 for col in bounds.min_col..=bounds.max_col {
635 let value = sheet.get_cell((col, row)).and_then(cell_to_value);
636 row_values.push(value);
637 }
638 rows.push(row_values);
639 }
640
641 out.push(RangeValuesEntry {
642 range,
643 rows: Some(rows),
644 formulas: None,
645 values: None,
646 dense: None,
647 csv: None,
648 rows_keyed: None,
649 next_start_row: None,
650 });
651 }
652
653 Ok(out)
654 }
655
656 pub fn sheet_page(&self, params: SessionSheetPageParams) -> Result<SheetPageResponse> {
658 if params.page_size == 0 {
659 return Err(anyhow!("page_size must be greater than zero"));
660 }
661
662 let sheet = self.sheet_by_name_required(¶ms.sheet_name)?;
663 let start_row = params.start_row.max(1);
664 let page_size = params.page_size.min(500);
665 let max_row = sheet.get_highest_row();
666
667 let page = build_sheet_page(
668 sheet,
669 start_row,
670 page_size,
671 params.columns.as_ref(),
672 params.columns_by_header.as_ref(),
673 params.include_formulas,
674 params.include_styles,
675 params.include_header,
676 )?;
677
678 let last_row_index = page
679 .rows
680 .last()
681 .map(|row| row.row_index)
682 .unwrap_or(start_row.saturating_sub(1));
683 let next_start_row = if last_row_index < max_row {
684 Some(last_row_index + 1)
685 } else {
686 None
687 };
688
689 Ok(build_sheet_page_response(
690 WorkbookId("session".to_string()),
691 params.sheet_name,
692 params.format,
693 params.include_header,
694 page.header,
695 page.rows,
696 next_start_row,
697 ))
698 }
699
700 pub fn grid_export(&self, sheet_name: &str, range: &str) -> Result<GridPayload> {
702 let sheet = self.sheet_by_name_required(sheet_name)?;
703 let bounds = parse_range_bounds(range)?;
704
705 let mut columns = Vec::new();
706 for col_idx in bounds.min_col..=bounds.max_col {
707 if let Some(dim) = sheet.get_column_dimension_by_number(&col_idx) {
708 let width = *dim.get_width();
709 if width > 0.0 {
710 columns.push(GridColumnHint {
711 offset: col_idx - bounds.min_col,
712 width_chars: width,
713 });
714 }
715 }
716 }
717
718 let mut merges = Vec::new();
719 for merge_cell in sheet.get_merge_cells() {
720 let merge_range = merge_cell.get_range();
721 if let Ok(merge_bounds) = parse_range_bounds(&merge_range)
722 && merge_bounds.min_col <= bounds.max_col
723 && merge_bounds.max_col >= bounds.min_col
724 && merge_bounds.min_row <= bounds.max_row
725 && merge_bounds.max_row >= bounds.min_row
726 {
727 merges.push(merge_range.to_string());
728 }
729 }
730
731 let mut rows = Vec::new();
732 for row in bounds.min_row..=bounds.max_row {
733 let mut cells = Vec::new();
734 for col in bounds.min_col..=bounds.max_col {
735 let Some(cell) = sheet.get_cell((&col, &row)) else {
736 continue;
737 };
738
739 let (value, formula) = if cell.is_formula() {
740 (None, Some(format!("={}", cell.get_formula())))
741 } else {
742 (cell_to_json_value(cell_to_value(cell)), None)
743 };
744
745 let descriptor = descriptor_from_style(cell.get_style());
746 let number_format = descriptor.number_format.clone();
747 let style_patch = style_descriptor_to_patch(descriptor);
748
749 if value.is_some()
750 || formula.is_some()
751 || number_format.is_some()
752 || style_patch.is_some()
753 {
754 cells.push(GridCell {
755 offset: [row - bounds.min_row, col - bounds.min_col],
756 v: value,
757 f: formula,
758 fmt: number_format,
759 style: style_patch,
760 });
761 }
762 }
763 if !cells.is_empty() {
764 rows.push(GridRow { cells });
765 }
766 }
767
768 Ok(GridPayload {
769 sheet: sheet_name.to_string(),
770 anchor: crate::utils::cell_address(bounds.min_col, bounds.min_row),
771 columns,
772 merges,
773 rows,
774 })
775 }
776
777 pub fn apply_ops(&mut self, ops: &[SessionTransformOp]) -> Result<SessionApplySummary> {
781 self.validate_ops(ops)?;
783
784 let mut summary = SessionApplySummary {
785 ops_applied: ops.len(),
786 ..Default::default()
787 };
788
789 for op in ops {
790 match op {
791 SessionTransformOp::WriteMatrix {
792 sheet_name,
793 anchor,
794 rows,
795 overwrite_formulas,
796 } => {
797 let sheet = self.sheet_by_name_mut(sheet_name)?;
798 let (anchor_col, anchor_row) = parse_cell_ref(anchor)?;
799
800 for (row_offset, row_values) in rows.iter().enumerate() {
801 let row_idx = anchor_row + row_offset as u32;
802 for (col_offset, cell_value) in row_values.iter().enumerate() {
803 let Some(cell_value) = cell_value else {
804 continue;
805 };
806 let col_idx = anchor_col + col_offset as u32;
807 let cell = sheet.get_cell_mut((col_idx, row_idx));
808 summary.cells_touched += 1;
809
810 if cell.is_formula() {
811 if !*overwrite_formulas {
812 summary.cells_skipped_keep_formulas += 1;
813 continue;
814 }
815 cell.set_formula(String::new());
816 summary.cells_formula_cleared += 1;
817 }
818
819 match cell_value {
820 SessionMatrixCell::Value(raw) => {
821 cell.set_value(json_value_to_cell_string(raw));
822 summary.cells_value_set += 1;
823 }
824 SessionMatrixCell::Formula(formula) => {
825 let formula = formula.strip_prefix('=').unwrap_or(formula);
826 cell.set_formula(formula.to_string());
827 cell.set_formula_result_default("");
828 summary.cells_formula_set += 1;
829 }
830 }
831 }
832 }
833 }
834 }
835 }
836
837 Ok(summary)
838 }
839
840 pub fn apply_write_matrix(
842 &mut self,
843 sheet_name: impl Into<String>,
844 anchor: impl Into<String>,
845 rows: Vec<Vec<Option<SessionMatrixCell>>>,
846 overwrite_formulas: bool,
847 ) -> Result<SessionApplySummary> {
848 let op = SessionTransformOp::WriteMatrix {
849 sheet_name: sheet_name.into(),
850 anchor: anchor.into(),
851 rows,
852 overwrite_formulas,
853 };
854 self.apply_ops(&[op])
855 }
856
857 pub fn to_bytes(&self) -> Result<Vec<u8>> {
859 let mut bytes = Vec::new();
860 umya_spreadsheet::writer::xlsx::write_writer(&self.spreadsheet, &mut bytes)
861 .context("failed to serialize workbook to bytes")?;
862 Ok(bytes)
863 }
864
865 pub fn to_temp_file(&self) -> Result<tempfile::NamedTempFile> {
870 let bytes = self.to_bytes()?;
871 let mut tmp = tempfile::Builder::new()
872 .suffix(".xlsx")
873 .tempfile()
874 .context("failed to create session temp file")?;
875 std::io::Write::write_all(&mut tmp, &bytes)
876 .context("failed to write workbook to temp file")?;
877 Ok(tmp)
878 }
879
880 pub fn reload_from_path(&mut self, path: &Path) -> Result<()> {
885 let bytes = fs::read(path)
886 .with_context(|| format!("failed to read workbook from '{}'", path.display()))?;
887 let cursor = std::io::Cursor::new(&bytes);
888 let spreadsheet = umya_spreadsheet::reader::xlsx::read_reader(cursor, true)
889 .context("failed to parse workbook after reload")?;
890 self.spreadsheet = spreadsheet;
891 Ok(())
892 }
893
894 pub fn into_bytes(self) -> Result<Vec<u8>> {
896 self.to_bytes()
897 }
898
899 fn as_workbook_context(&self) -> Result<WorkbookContext> {
900 let bytes = self.to_bytes()?;
901 let workbook_id = WorkbookId("session".to_string());
902 let short_id = crate::utils::make_short_workbook_id("session", workbook_id.as_str());
903 let config = Arc::new(ServerConfig {
904 workspace_root: PathBuf::from("."),
905 screenshot_dir: PathBuf::from("screenshots"),
906 path_mappings: Vec::new(),
907 cache_capacity: 2,
908 supported_extensions: vec![
909 "xlsx".to_string(),
910 "xlsm".to_string(),
911 "xls".to_string(),
912 "xlsb".to_string(),
913 ],
914 single_workbook: None,
915 enabled_tools: None,
916 transport: TransportKind::Stdio,
917 http_bind_address: "127.0.0.1:8079"
918 .parse()
919 .expect("hardcoded bind address is valid"),
920 recalc_enabled: false,
921 recalc_backend: RecalcBackendKind::Auto,
922 vba_enabled: false,
923 max_concurrent_recalcs: 1,
924 tool_timeout_ms: Some(30_000),
925 max_response_bytes: Some(1_000_000),
926 output_profile: OutputProfile::Verbose,
927 max_payload_bytes: Some(65_536),
928 max_cells: Some(10_000),
929 max_items: Some(500),
930 allow_overwrite: true,
931 });
932
933 WorkbookContext::load_from_bytes(
934 &config,
935 "session.xlsx",
936 &bytes,
937 workbook_id,
938 short_id,
939 None,
940 )
941 }
942
943 pub fn sheet_by_name(&self, sheet_name: &str) -> Option<&Worksheet> {
945 self.spreadsheet.get_sheet_by_name(sheet_name)
946 }
947
948 fn sheet_by_name_required(&self, sheet_name: &str) -> Result<&Worksheet> {
949 self.spreadsheet
950 .get_sheet_by_name(sheet_name)
951 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))
952 }
953
954 fn sheet_by_name_mut(&mut self, sheet_name: &str) -> Result<&mut Worksheet> {
955 self.spreadsheet
956 .get_sheet_by_name_mut(sheet_name)
957 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))
958 }
959
960 fn validate_ops(&self, ops: &[SessionTransformOp]) -> Result<()> {
961 for op in ops {
962 match op {
963 SessionTransformOp::WriteMatrix {
964 sheet_name, anchor, ..
965 } => {
966 self.sheet_by_name_required(sheet_name)?;
967 let _ = parse_cell_ref(anchor)?;
968 }
969 }
970 }
971 Ok(())
972 }
973}
974
975fn default_start_row() -> u32 {
976 1
977}
978
979fn default_page_size() -> u32 {
980 50
981}
982
983fn default_include_formulas() -> bool {
984 true
985}
986
987fn default_include_styles() -> bool {
988 false
989}
990
991fn default_include_header() -> bool {
992 true
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, Default)]
996#[serde(rename_all = "snake_case")]
997pub struct SessionSheetOverviewParams {
998 pub sheet_name: String,
999 #[serde(default)]
1000 pub max_regions: Option<u32>,
1001 #[serde(default)]
1002 pub max_headers: Option<u32>,
1003 #[serde(default)]
1004 pub include_headers: Option<bool>,
1005}
1006
1007fn default_find_limit() -> u32 {
1008 50
1009}
1010
1011fn default_read_table_limit() -> u32 {
1012 100
1013}
1014
1015fn default_read_table_include_headers() -> bool {
1016 true
1017}
1018
1019fn default_read_table_include_types() -> bool {
1020 false
1021}
1022
1023fn default_read_table_format() -> TableOutputFormat {
1024 TableOutputFormat::Csv
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028#[serde(rename_all = "snake_case")]
1029pub struct SessionFindValueParams {
1030 pub query: String,
1031 #[serde(default)]
1032 pub sheet_name: Option<String>,
1033 #[serde(default)]
1034 pub case_sensitive: bool,
1035 #[serde(default = "default_find_limit")]
1036 pub limit: u32,
1037 #[serde(default)]
1038 pub offset: Option<u32>,
1039}
1040
1041impl Default for SessionFindValueParams {
1042 fn default() -> Self {
1043 Self {
1044 query: String::new(),
1045 sheet_name: None,
1046 case_sensitive: false,
1047 limit: default_find_limit(),
1048 offset: None,
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[serde(rename_all = "snake_case")]
1055pub struct SessionReadTableParams {
1056 #[serde(default)]
1057 pub sheet_name: Option<String>,
1058 #[serde(default)]
1059 pub range: Option<String>,
1060 #[serde(default)]
1061 pub columns: Option<Vec<String>>,
1062 #[serde(default = "default_read_table_limit")]
1063 pub limit: u32,
1064 #[serde(default)]
1065 pub offset: Option<u32>,
1066 #[serde(default = "default_read_table_format")]
1067 pub format: TableOutputFormat,
1068 #[serde(default = "default_read_table_include_headers")]
1069 pub include_headers: bool,
1070 #[serde(default = "default_read_table_include_types")]
1071 pub include_types: bool,
1072}
1073
1074impl Default for SessionReadTableParams {
1075 fn default() -> Self {
1076 Self {
1077 sheet_name: None,
1078 range: None,
1079 columns: None,
1080 limit: default_read_table_limit(),
1081 offset: None,
1082 format: default_read_table_format(),
1083 include_headers: default_read_table_include_headers(),
1084 include_types: default_read_table_include_types(),
1085 }
1086 }
1087}
1088
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090#[serde(rename_all = "snake_case")]
1091pub struct SessionSheetPageParams {
1092 pub sheet_name: String,
1093 #[serde(default = "default_start_row")]
1094 pub start_row: u32,
1095 #[serde(default = "default_page_size")]
1096 pub page_size: u32,
1097 #[serde(default)]
1098 pub columns: Option<Vec<String>>,
1099 #[serde(default)]
1100 pub columns_by_header: Option<Vec<String>>,
1101 #[serde(default = "default_include_formulas")]
1102 pub include_formulas: bool,
1103 #[serde(default = "default_include_styles")]
1104 pub include_styles: bool,
1105 #[serde(default = "default_include_header")]
1106 pub include_header: bool,
1107 #[serde(default)]
1108 pub format: SheetPageFormat,
1109}
1110
1111impl SessionSheetPageParams {
1112 pub fn with_sheet_name(sheet_name: impl Into<String>) -> Self {
1113 Self {
1114 sheet_name: sheet_name.into(),
1115 ..Self::default()
1116 }
1117 }
1118}
1119
1120impl Default for SessionSheetPageParams {
1121 fn default() -> Self {
1122 Self {
1123 sheet_name: String::new(),
1124 start_row: default_start_row(),
1125 page_size: default_page_size(),
1126 columns: None,
1127 columns_by_header: None,
1128 include_formulas: default_include_formulas(),
1129 include_styles: default_include_styles(),
1130 include_header: default_include_header(),
1131 format: SheetPageFormat::default(),
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone)]
1137pub enum SessionRangeSelection {
1138 Single(String),
1139 Multi(Vec<String>),
1140}
1141
1142impl SessionRangeSelection {
1143 fn into_vec(self) -> Vec<String> {
1144 match self {
1145 SessionRangeSelection::Single(range) => vec![range],
1146 SessionRangeSelection::Multi(ranges) => ranges,
1147 }
1148 }
1149}
1150
1151impl From<String> for SessionRangeSelection {
1152 fn from(value: String) -> Self {
1153 SessionRangeSelection::Single(value)
1154 }
1155}
1156
1157impl From<&str> for SessionRangeSelection {
1158 fn from(value: &str) -> Self {
1159 SessionRangeSelection::Single(value.to_string())
1160 }
1161}
1162
1163impl From<Vec<String>> for SessionRangeSelection {
1164 fn from(value: Vec<String>) -> Self {
1165 SessionRangeSelection::Multi(value)
1166 }
1167}
1168
1169impl From<Vec<&str>> for SessionRangeSelection {
1170 fn from(value: Vec<&str>) -> Self {
1171 SessionRangeSelection::Multi(value.into_iter().map(str::to_string).collect())
1172 }
1173}
1174
1175impl From<&[String]> for SessionRangeSelection {
1176 fn from(value: &[String]) -> Self {
1177 SessionRangeSelection::Multi(value.to_vec())
1178 }
1179}
1180
1181impl From<&[&str]> for SessionRangeSelection {
1182 fn from(value: &[&str]) -> Self {
1183 SessionRangeSelection::Multi(value.iter().map(|entry| entry.to_string()).collect())
1184 }
1185}
1186
1187impl<const N: usize> From<[&str; N]> for SessionRangeSelection {
1188 fn from(value: [&str; N]) -> Self {
1189 SessionRangeSelection::Multi(value.into_iter().map(str::to_string).collect())
1190 }
1191}
1192
1193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1194pub enum SessionMatrixCell {
1195 #[serde(rename = "v")]
1196 Value(serde_json::Value),
1197 #[serde(rename = "f")]
1198 Formula(String),
1199}
1200
1201fn default_overwrite_formulas() -> bool {
1202 false
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1206#[serde(tag = "kind", rename_all = "snake_case")]
1207pub enum SessionTransformOp {
1208 WriteMatrix {
1209 sheet_name: String,
1210 anchor: String,
1211 rows: Vec<Vec<Option<SessionMatrixCell>>>,
1212 #[serde(default = "default_overwrite_formulas")]
1213 overwrite_formulas: bool,
1214 },
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1218pub struct SessionApplySummary {
1219 pub ops_applied: usize,
1220 pub cells_touched: u64,
1221 pub cells_value_set: u64,
1222 pub cells_formula_set: u64,
1223 pub cells_formula_cleared: u64,
1224 pub cells_skipped_keep_formulas: u64,
1225}
1226
1227#[derive(Debug, Clone, Copy)]
1228struct RangeBounds {
1229 min_col: u32,
1230 min_row: u32,
1231 max_col: u32,
1232 max_row: u32,
1233}
1234
1235fn resolve_sheet_index_on_spreadsheet(
1236 book: &umya_spreadsheet::Spreadsheet,
1237 sheet_name: &str,
1238) -> Result<u32> {
1239 for (idx, sheet) in book.get_sheet_collection().iter().enumerate() {
1240 if sheet.get_name() == sheet_name {
1241 return Ok(idx as u32);
1242 }
1243 }
1244 Err(anyhow!("sheet '{}' not found", sheet_name))
1245}
1246
1247fn parse_cell_ref(cell: &str) -> Result<(u32, u32)> {
1248 use umya_spreadsheet::helper::coordinate::index_from_coordinate;
1249
1250 let (col, row, _, _) = index_from_coordinate(cell);
1251 match (col, row) {
1252 (Some(c), Some(r)) => Ok((c, r)),
1253 _ => Err(anyhow!("invalid cell reference: {}", cell)),
1254 }
1255}
1256
1257fn parse_range_bounds(range: &str) -> Result<RangeBounds> {
1258 let parts: Vec<&str> = range.split(':').collect();
1259 if parts.is_empty() || parts.len() > 2 {
1260 return Err(anyhow!(
1261 "invalid range '{}'; expected 'A1' or 'A1:Z99'",
1262 range
1263 ));
1264 }
1265
1266 let start = parse_cell_ref(parts[0])?;
1267 let end = if parts.len() == 2 {
1268 parse_cell_ref(parts[1])?
1269 } else {
1270 start
1271 };
1272
1273 Ok(RangeBounds {
1274 min_col: start.0.min(end.0),
1275 min_row: start.1.min(end.1),
1276 max_col: start.0.max(end.0),
1277 max_row: start.1.max(end.1),
1278 })
1279}
1280
1281fn json_value_to_cell_string(value: &serde_json::Value) -> String {
1282 match value {
1283 serde_json::Value::Null => String::new(),
1284 serde_json::Value::Bool(raw) => raw.to_string(),
1285 serde_json::Value::Number(raw) => raw.to_string(),
1286 serde_json::Value::String(raw) => raw.clone(),
1287 serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
1288 }
1289}
1290
1291fn cell_to_json_value(value: Option<crate::model::CellValue>) -> Option<serde_json::Value> {
1292 match value {
1293 Some(crate::model::CellValue::Text(text)) => Some(serde_json::Value::String(text)),
1294 Some(crate::model::CellValue::Number(number)) => Some(serde_json::json!(number)),
1295 Some(crate::model::CellValue::Bool(value)) => Some(serde_json::Value::Bool(value)),
1296 Some(crate::model::CellValue::Error(text)) => Some(serde_json::Value::String(text)),
1297 Some(crate::model::CellValue::Date(text)) => Some(serde_json::Value::String(text)),
1298 None => None,
1299 }
1300}
1301
1302fn style_descriptor_to_patch(desc: crate::model::StyleDescriptor) -> Option<StylePatch> {
1303 if desc.font.is_none()
1304 && desc.fill.is_none()
1305 && desc.borders.is_none()
1306 && desc.alignment.is_none()
1307 {
1308 return None;
1309 }
1310
1311 Some(StylePatch {
1312 font: desc.font.map(|font| {
1313 Some(crate::model::FontPatch {
1314 name: font.name.map(Some),
1315 size: font.size.map(Some),
1316 bold: font.bold.map(Some),
1317 italic: font.italic.map(Some),
1318 underline: font.underline.map(Some),
1319 strikethrough: font.strikethrough.map(Some),
1320 color: font.color.map(Some),
1321 })
1322 }),
1323 fill: desc.fill.map(|fill| {
1324 Some(match fill {
1325 crate::model::FillDescriptor::Pattern(pattern) => {
1326 crate::model::FillPatch::Pattern(crate::model::PatternFillPatch {
1327 pattern_type: pattern.pattern_type.map(Some),
1328 foreground_color: pattern.foreground_color.map(Some),
1329 background_color: pattern.background_color.map(Some),
1330 })
1331 }
1332 crate::model::FillDescriptor::Gradient(gradient) => {
1333 crate::model::FillPatch::Gradient(crate::model::GradientFillPatch {
1334 degree: gradient.degree.map(Some),
1335 stops: Some(
1336 gradient
1337 .stops
1338 .into_iter()
1339 .map(|stop| crate::model::GradientStopPatch {
1340 position: stop.position,
1341 color: stop.color,
1342 })
1343 .collect(),
1344 ),
1345 })
1346 }
1347 })
1348 }),
1349 borders: desc.borders.map(|borders| {
1350 Some(crate::model::BordersPatch {
1351 left: borders.left.map(|side| {
1352 Some(crate::model::BorderSidePatch {
1353 style: side.style.map(Some),
1354 color: side.color.map(Some),
1355 })
1356 }),
1357 right: borders.right.map(|side| {
1358 Some(crate::model::BorderSidePatch {
1359 style: side.style.map(Some),
1360 color: side.color.map(Some),
1361 })
1362 }),
1363 top: borders.top.map(|side| {
1364 Some(crate::model::BorderSidePatch {
1365 style: side.style.map(Some),
1366 color: side.color.map(Some),
1367 })
1368 }),
1369 bottom: borders.bottom.map(|side| {
1370 Some(crate::model::BorderSidePatch {
1371 style: side.style.map(Some),
1372 color: side.color.map(Some),
1373 })
1374 }),
1375 diagonal: borders.diagonal.map(|side| {
1376 Some(crate::model::BorderSidePatch {
1377 style: side.style.map(Some),
1378 color: side.color.map(Some),
1379 })
1380 }),
1381 vertical: borders.vertical.map(|side| {
1382 Some(crate::model::BorderSidePatch {
1383 style: side.style.map(Some),
1384 color: side.color.map(Some),
1385 })
1386 }),
1387 horizontal: borders.horizontal.map(|side| {
1388 Some(crate::model::BorderSidePatch {
1389 style: side.style.map(Some),
1390 color: side.color.map(Some),
1391 })
1392 }),
1393 diagonal_up: borders.diagonal_up.map(Some),
1394 diagonal_down: borders.diagonal_down.map(Some),
1395 })
1396 }),
1397 alignment: desc.alignment.map(|alignment| {
1398 Some(crate::model::AlignmentPatch {
1399 horizontal: alignment.horizontal.map(Some),
1400 vertical: alignment.vertical.map(Some),
1401 wrap_text: alignment.wrap_text.map(Some),
1402 text_rotation: alignment.text_rotation.map(Some),
1403 })
1404 }),
1405 number_format: None,
1406 })
1407}
1408
1409struct PageBuildResult {
1410 rows: Vec<RowSnapshot>,
1411 header: Option<RowSnapshot>,
1412}
1413
1414#[allow(clippy::too_many_arguments)]
1415fn build_sheet_page(
1416 sheet: &umya_spreadsheet::Worksheet,
1417 start_row: u32,
1418 page_size: u32,
1419 columns: Option<&Vec<String>>,
1420 columns_by_header: Option<&Vec<String>>,
1421 include_formulas: bool,
1422 include_styles: bool,
1423 include_header: bool,
1424) -> Result<PageBuildResult> {
1425 let max_col = sheet.get_highest_column();
1426 let end_row = start_row
1427 .saturating_add(page_size.saturating_sub(1))
1428 .min(sheet.get_highest_row());
1429 let column_indices = resolve_columns_with_headers(sheet, columns, columns_by_header, max_col)?;
1430
1431 let header = if include_header {
1432 Some(build_row_snapshot(
1433 sheet,
1434 1,
1435 &column_indices,
1436 include_formulas,
1437 include_styles,
1438 ))
1439 } else {
1440 None
1441 };
1442
1443 let mut rows = Vec::new();
1444 for row_idx in start_row..=end_row {
1445 rows.push(build_row_snapshot(
1446 sheet,
1447 row_idx,
1448 &column_indices,
1449 include_formulas,
1450 include_styles,
1451 ));
1452 }
1453
1454 Ok(PageBuildResult { rows, header })
1455}
1456
1457fn build_row_snapshot(
1458 sheet: &umya_spreadsheet::Worksheet,
1459 row_index: u32,
1460 columns: &[u32],
1461 include_formulas: bool,
1462 include_styles: bool,
1463) -> RowSnapshot {
1464 let mut cells = Vec::new();
1465 for &col in columns {
1466 if let Some(cell) = sheet.get_cell((col, row_index)) {
1467 cells.push(build_cell_snapshot(cell, include_formulas, include_styles));
1468 } else {
1469 let address = crate::utils::cell_address(col, row_index);
1470 cells.push(CellSnapshot {
1471 address,
1472 value: None,
1473 formula: None,
1474 cached_value: None,
1475 number_format: None,
1476 style_tags: Vec::new(),
1477 notes: Vec::new(),
1478 });
1479 }
1480 }
1481
1482 RowSnapshot { row_index, cells }
1483}
1484
1485fn build_cell_snapshot(
1486 cell: &umya_spreadsheet::Cell,
1487 include_formulas: bool,
1488 include_styles: bool,
1489) -> CellSnapshot {
1490 let address = cell.get_coordinate().get_coordinate();
1491 let value = crate::workbook::cell_to_value(cell);
1492 let formula = if include_formulas && cell.is_formula() {
1493 Some(cell.get_formula().to_string())
1494 } else {
1495 None
1496 };
1497 let cached_value = if cell.is_formula() {
1498 value.clone()
1499 } else {
1500 None
1501 };
1502 let number_format = if include_styles {
1503 cell.get_style()
1504 .get_number_format()
1505 .map(|fmt| fmt.get_format_code().to_string())
1506 } else {
1507 None
1508 };
1509 let style_tags = if include_styles {
1510 crate::analysis::style::tag_cell(cell)
1511 .map(|(_, tagging)| tagging.tags)
1512 .unwrap_or_default()
1513 } else {
1514 Vec::new()
1515 };
1516
1517 CellSnapshot {
1518 address,
1519 value,
1520 formula,
1521 cached_value,
1522 number_format,
1523 style_tags,
1524 notes: Vec::new(),
1525 }
1526}
1527
1528fn parse_column_index(spec: &str) -> Result<u32> {
1529 use umya_spreadsheet::helper::coordinate::column_index_from_string;
1530
1531 let trimmed = spec.trim();
1532 if trimmed.is_empty() {
1533 return Err(anyhow!("invalid column spec: empty"));
1534 }
1535 if !trimmed.chars().all(|c| c.is_ascii_alphabetic()) {
1536 return Err(anyhow!(
1537 "invalid column spec '{}'; expected letters like 'A' or 'A:C'",
1538 spec
1539 ));
1540 }
1541 if trimmed.len() > 3 {
1542 return Err(anyhow!(
1543 "invalid column spec '{}'; expected at most 3 column letters",
1544 spec
1545 ));
1546 }
1547
1548 Ok(column_index_from_string(trimmed.to_ascii_uppercase()))
1549}
1550
1551fn resolve_columns(columns: Option<&Vec<String>>, max_column: u32) -> Result<Vec<u32>> {
1552 use std::collections::BTreeSet;
1553
1554 let mut indices = BTreeSet::new();
1555 if let Some(specs) = columns {
1556 for spec in specs {
1557 if let Some((start, end)) = spec.split_once(':') {
1558 let start_idx = parse_column_index(start)?;
1559 let end_idx = parse_column_index(end)?;
1560 let (min_idx, max_idx) = if start_idx <= end_idx {
1561 (start_idx, end_idx)
1562 } else {
1563 (end_idx, start_idx)
1564 };
1565 for idx in min_idx..=max_idx {
1566 indices.insert(idx);
1567 }
1568 } else {
1569 indices.insert(parse_column_index(spec)?);
1570 }
1571 }
1572 } else {
1573 for idx in 1..=max_column.max(1) {
1574 indices.insert(idx);
1575 }
1576 }
1577
1578 Ok(indices.into_iter().collect())
1579}
1580
1581fn resolve_columns_with_headers(
1582 sheet: &umya_spreadsheet::Worksheet,
1583 columns: Option<&Vec<String>>,
1584 columns_by_header: Option<&Vec<String>>,
1585 max_column: u32,
1586) -> Result<Vec<u32>> {
1587 use std::collections::BTreeSet;
1588
1589 if columns_by_header.is_none() {
1590 return resolve_columns(columns, max_column);
1591 }
1592
1593 let mut selected: BTreeSet<u32> = if columns.is_some() {
1594 resolve_columns(columns, max_column)?.into_iter().collect()
1595 } else {
1596 BTreeSet::new()
1597 };
1598 let mut matched_header = false;
1599 let header_targets: Vec<String> = columns_by_header
1600 .expect("checked")
1601 .iter()
1602 .map(|h| h.trim().to_ascii_lowercase())
1603 .collect();
1604
1605 for col_idx in 1..=max_column.max(1) {
1606 let header_cell = sheet.get_cell((col_idx, 1u32));
1607 let header_value = header_cell
1608 .and_then(cell_to_value)
1609 .map(cell_value_to_string_lower);
1610 if let Some(hval) = header_value
1611 && header_targets.iter().any(|target| target == &hval)
1612 {
1613 selected.insert(col_idx);
1614 matched_header = true;
1615 }
1616 }
1617
1618 if !matched_header && columns.is_none() {
1619 resolve_columns(None, max_column)
1620 } else {
1621 Ok(selected.into_iter().collect())
1622 }
1623}
1624
1625fn cell_value_to_string(value: CellValue) -> String {
1626 match value {
1627 CellValue::Text(s) => s,
1628 CellValue::Number(n) => n.to_string(),
1629 CellValue::Bool(b) => b.to_string(),
1630 CellValue::Error(e) => e,
1631 CellValue::Date(d) => d,
1632 }
1633}
1634
1635fn cell_value_to_string_lower(value: CellValue) -> String {
1636 cell_value_to_string(value).to_ascii_lowercase()
1637}
1638
1639fn cell_value_to_primitive(value: &CellValue) -> Option<CellValuePrimitive> {
1640 match value {
1641 CellValue::Text(s) => Some(CellValuePrimitive::Text(s.clone())),
1642 CellValue::Number(n) => Some(CellValuePrimitive::Number(*n)),
1643 CellValue::Bool(b) => Some(CellValuePrimitive::Bool(*b)),
1644 CellValue::Error(e) => Some(CellValuePrimitive::Text(e.clone())),
1645 CellValue::Date(d) => Some(CellValuePrimitive::Text(d.clone())),
1646 }
1647}
1648
1649fn cell_value_kind(value: &CellValue) -> CellValueKind {
1650 match value {
1651 CellValue::Text(_) => CellValueKind::Text,
1652 CellValue::Number(_) => CellValueKind::Number,
1653 CellValue::Bool(_) => CellValueKind::Bool,
1654 CellValue::Error(_) => CellValueKind::Error,
1655 CellValue::Date(_) => CellValueKind::Date,
1656 }
1657}
1658
1659fn build_csv_payload(
1660 headers: &[String],
1661 rows: &[Vec<Option<CellValue>>],
1662 include_headers: bool,
1663) -> String {
1664 fn escape_csv(value: &str) -> String {
1665 if value.contains(',')
1666 || value.contains('"')
1667 || value.contains('\n')
1668 || value.contains('\r')
1669 {
1670 format!("\"{}\"", value.replace('"', "\"\""))
1671 } else {
1672 value.to_string()
1673 }
1674 }
1675
1676 let mut out = String::new();
1677 if include_headers {
1678 out.push_str(
1679 &headers
1680 .iter()
1681 .map(|h| escape_csv(h))
1682 .collect::<Vec<_>>()
1683 .join(","),
1684 );
1685 out.push('\n');
1686 }
1687
1688 for row in rows {
1689 let line = row
1690 .iter()
1691 .map(|cell| match cell {
1692 Some(CellValue::Text(s)) => escape_csv(s),
1693 Some(CellValue::Number(n)) => n.to_string(),
1694 Some(CellValue::Bool(b)) => b.to_string(),
1695 Some(CellValue::Error(e)) => escape_csv(e),
1696 Some(CellValue::Date(d)) => escape_csv(d),
1697 None => String::new(),
1698 })
1699 .collect::<Vec<_>>()
1700 .join(",");
1701 out.push_str(&line);
1702 out.push('\n');
1703 }
1704
1705 out
1706}
1707
1708fn dedupe_headers_in_place(headers: &mut [String]) {
1709 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1710 for header in headers.iter_mut() {
1711 let base = if header.trim().is_empty() {
1712 "column".to_string()
1713 } else {
1714 header.clone()
1715 };
1716 let counter = counts.entry(base.clone()).or_insert(0);
1717 if *counter == 0 {
1718 *header = base;
1719 } else {
1720 *header = format!("{}_{}", base, *counter + 1);
1721 }
1722 *counter += 1;
1723 }
1724}
1725
1726fn build_compact_payload(
1727 header: &Option<RowSnapshot>,
1728 rows: &[RowSnapshot],
1729 include_header: bool,
1730) -> SheetPageCompact {
1731 let headers = derive_headers(header, rows);
1732 let header_row = if include_header {
1733 header
1734 .as_ref()
1735 .map(|h| h.cells.iter().map(|c| c.value.clone()).collect())
1736 .unwrap_or_default()
1737 } else {
1738 Vec::new()
1739 };
1740 let data_rows = rows
1741 .iter()
1742 .map(|row| {
1743 let mut vals: Vec<Option<CellValue>> = Vec::new();
1744 vals.push(Some(CellValue::Number(row.row_index as f64)));
1745 vals.extend(row.cells.iter().map(|c| c.value.clone()));
1746 vals
1747 })
1748 .collect();
1749
1750 SheetPageCompact {
1751 headers,
1752 header_row,
1753 rows: data_rows,
1754 }
1755}
1756
1757fn build_values_only_payload(
1758 header: &Option<RowSnapshot>,
1759 rows: &[RowSnapshot],
1760 include_header: bool,
1761) -> SheetPageValues {
1762 let mut data = Vec::new();
1763 if include_header && let Some(h) = header {
1764 data.push(h.cells.iter().map(|c| c.value.clone()).collect());
1765 }
1766 for row in rows {
1767 data.push(row.cells.iter().map(|c| c.value.clone()).collect());
1768 }
1769
1770 SheetPageValues { rows: data }
1771}
1772
1773fn build_sheet_page_response(
1774 workbook_id: WorkbookId,
1775 sheet_name: String,
1776 format: SheetPageFormat,
1777 include_header: bool,
1778 header: Option<RowSnapshot>,
1779 rows: Vec<RowSnapshot>,
1780 next_start_row: Option<u32>,
1781) -> SheetPageResponse {
1782 let compact_payload = if matches!(format, SheetPageFormat::Compact) {
1783 Some(build_compact_payload(&header, &rows, include_header))
1784 } else {
1785 None
1786 };
1787
1788 let values_only_payload = if matches!(format, SheetPageFormat::ValuesOnly) {
1789 Some(build_values_only_payload(&header, &rows, include_header))
1790 } else {
1791 None
1792 };
1793
1794 let rows_payload = if matches!(format, SheetPageFormat::Full) {
1795 rows
1796 } else {
1797 Vec::new()
1798 };
1799
1800 let header_row = if include_header && matches!(format, SheetPageFormat::Full) {
1801 header
1802 } else {
1803 None
1804 };
1805
1806 SheetPageResponse {
1807 workbook_id,
1808 sheet_name,
1809 rows: rows_payload,
1810 next_start_row,
1811 header_row,
1812 compact: compact_payload,
1813 values_only: values_only_payload,
1814 format,
1815 truncated: false,
1816 budget: None,
1817 }
1818}
1819
1820fn derive_headers(header: &Option<RowSnapshot>, rows: &[RowSnapshot]) -> Vec<String> {
1821 if let Some(h) = header {
1822 let mut headers: Vec<String> = h
1823 .cells
1824 .iter()
1825 .map(|c| match &c.value {
1826 Some(CellValue::Text(t)) => t.clone(),
1827 Some(CellValue::Number(n)) => n.to_string(),
1828 Some(CellValue::Bool(b)) => b.to_string(),
1829 Some(CellValue::Date(d)) => d.clone(),
1830 Some(CellValue::Error(e)) => e.clone(),
1831 None => c.address.clone(),
1832 })
1833 .collect();
1834 headers.insert(0, "Row".to_string());
1835 headers
1836 } else if let Some(first) = rows.first() {
1837 let mut headers = Vec::new();
1838 headers.push("Row".to_string());
1839 for cell in &first.cells {
1840 headers.push(cell.address.clone());
1841 }
1842 headers
1843 } else {
1844 vec![]
1845 }
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850 use super::*;
1851 use crate::model::CellValue;
1852 use anyhow::Result;
1853 use tempfile::tempdir;
1854
1855 fn workbook_bytes(setup: impl FnOnce(&mut Spreadsheet)) -> Vec<u8> {
1856 let mut book = umya_spreadsheet::new_file();
1857 setup(&mut book);
1858
1859 let mut bytes = Vec::new();
1860 umya_spreadsheet::writer::xlsx::write_writer(&book, &mut bytes).expect("write workbook");
1861 bytes
1862 }
1863
1864 fn grid_cell(payload: &GridPayload, row_offset: u32, col_offset: u32) -> Option<&GridCell> {
1865 payload
1866 .rows
1867 .iter()
1868 .flat_map(|row| row.cells.iter())
1869 .find(|cell| cell.offset == [row_offset, col_offset])
1870 }
1871
1872 #[test]
1873 fn bytes_roundtrip_and_multi_range_reads() -> Result<()> {
1874 let bytes = workbook_bytes(|book| {
1875 book.get_sheet_by_name_mut("Sheet1")
1876 .expect("sheet")
1877 .get_cell_mut("A1")
1878 .set_value("hello");
1879 let _ = book.new_sheet("Data");
1880 book.get_sheet_by_name_mut("Data")
1881 .expect("data sheet")
1882 .get_cell_mut("B2")
1883 .set_value_number(42.0);
1884 });
1885
1886 let session = WorkbookSession::from_bytes(bytes)?;
1887 assert_eq!(session.list_sheets(), vec!["Sheet1", "Data"]);
1888
1889 let entries = session.range_values("Data", vec!["A1:B2", "B2:B2"])?;
1890 assert_eq!(entries.len(), 2);
1891
1892 let rows = entries[0].rows.as_ref().expect("rows");
1893 assert!(
1894 matches!(rows[1][1], Some(CellValue::Number(v)) if (v - 42.0).abs() < f64::EPSILON)
1895 );
1896
1897 let out_bytes = session.into_bytes()?;
1898 let reopened = WorkbookSession::from_bytes(out_bytes)?;
1899 let reopened_entries = reopened.range_values("Sheet1", "A1")?;
1900 let reopened_rows = reopened_entries[0].rows.as_ref().expect("rows");
1901 assert!(matches!(
1902 reopened_rows[0][0],
1903 Some(CellValue::Text(ref value)) if value == "hello"
1904 ));
1905
1906 Ok(())
1907 }
1908
1909 #[test]
1910 fn apply_write_matrix_updates_in_session_and_roundtrips() -> Result<()> {
1911 let bytes = workbook_bytes(|book| {
1912 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
1913 sheet.get_cell_mut("A1").set_value("before");
1914 sheet.get_cell_mut("B1").set_formula("1+1");
1915 });
1916
1917 let mut session = WorkbookSession::from_bytes(bytes)?;
1918
1919 let summary = session.apply_write_matrix(
1920 "Sheet1",
1921 "A1",
1922 vec![vec![
1923 Some(SessionMatrixCell::Value(serde_json::json!("after"))),
1924 Some(SessionMatrixCell::Value(serde_json::json!(99))),
1925 ]],
1926 false,
1927 )?;
1928
1929 assert_eq!(summary.ops_applied, 1);
1930 assert_eq!(summary.cells_skipped_keep_formulas, 1);
1931
1932 let before_overwrite = session.grid_export("Sheet1", "A1:B1")?;
1933 let b1_before = grid_cell(&before_overwrite, 0, 1).expect("B1 cell");
1934 assert_eq!(b1_before.f.as_deref(), Some("=1+1"));
1935
1936 session.apply_write_matrix(
1937 "Sheet1",
1938 "B1",
1939 vec![vec![Some(SessionMatrixCell::Formula(
1940 "=SUM(1,2)".to_string(),
1941 ))]],
1942 true,
1943 )?;
1944
1945 let after = session.grid_export("Sheet1", "A1:B1")?;
1946 let a1_after = grid_cell(&after, 0, 0).expect("A1 cell");
1947 let b1_after = grid_cell(&after, 0, 1).expect("B1 cell");
1948 assert_eq!(a1_after.v, Some(serde_json::json!("after")));
1949 assert_eq!(b1_after.f.as_deref(), Some("=SUM(1,2)"));
1950
1951 let roundtrip = WorkbookSession::from_bytes(session.into_bytes()?)?;
1952 let persisted = roundtrip.grid_export("Sheet1", "B1")?;
1953 let persisted_b1 = grid_cell(&persisted, 0, 0).expect("persisted B1");
1954 assert_eq!(persisted_b1.f.as_deref(), Some("=SUM(1,2)"));
1955
1956 Ok(())
1957 }
1958
1959 #[test]
1960 fn write_matrix_default_overwrite_formulas_is_false() -> Result<()> {
1961 let raw = serde_json::json!({
1962 "kind": "write_matrix",
1963 "sheet_name": "Sheet1",
1964 "anchor": "A1",
1965 "rows": [[{"v": "x"}]]
1966 });
1967
1968 let op: SessionTransformOp = serde_json::from_value(raw)?;
1969 match op {
1970 SessionTransformOp::WriteMatrix {
1971 overwrite_formulas, ..
1972 } => {
1973 assert!(!overwrite_formulas);
1974 }
1975 }
1976
1977 Ok(())
1978 }
1979
1980 #[test]
1981 fn apply_ops_is_atomic_on_validation_failure() -> Result<()> {
1982 let bytes = workbook_bytes(|book| {
1983 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
1984 sheet.get_cell_mut("A1").set_value("before");
1985 });
1986
1987 let mut session = WorkbookSession::from_bytes(bytes)?;
1988
1989 let ops = vec![
1990 SessionTransformOp::WriteMatrix {
1991 sheet_name: "Sheet1".to_string(),
1992 anchor: "A1".to_string(),
1993 rows: vec![vec![Some(SessionMatrixCell::Value(serde_json::json!(
1994 "after"
1995 )))]],
1996 overwrite_formulas: false,
1997 },
1998 SessionTransformOp::WriteMatrix {
1999 sheet_name: "MissingSheet".to_string(),
2000 anchor: "A1".to_string(),
2001 rows: vec![vec![Some(SessionMatrixCell::Value(serde_json::json!(
2002 "bad"
2003 )))]],
2004 overwrite_formulas: false,
2005 },
2006 ];
2007
2008 let err = session.apply_ops(&ops).unwrap_err();
2009 assert!(err.to_string().contains("MissingSheet"));
2010
2011 let after = session.range_values("Sheet1", "A1")?;
2012 let rows = after[0].rows.as_ref().expect("rows");
2013 assert!(matches!(
2014 rows[0][0],
2015 Some(CellValue::Text(ref value)) if value == "before"
2016 ));
2017
2018 Ok(())
2019 }
2020
2021 #[test]
2022 fn sheet_page_full_supports_paging_and_formulas() -> Result<()> {
2023 let bytes = workbook_bytes(|book| {
2024 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2025 sheet.get_cell_mut("A1").set_value("Name");
2026 sheet.get_cell_mut("B1").set_value("Calc");
2027 sheet.get_cell_mut("A2").set_value("alpha");
2028 sheet.get_cell_mut("B2").set_formula("1+1");
2029 sheet.get_cell_mut("A3").set_value("beta");
2030 sheet.get_cell_mut("B3").set_value_number(7.0);
2031 });
2032
2033 let session = WorkbookSession::from_bytes(bytes)?;
2034 let page = session.sheet_page(SessionSheetPageParams {
2035 sheet_name: "Sheet1".to_string(),
2036 start_row: 2,
2037 page_size: 1,
2038 include_formulas: true,
2039 format: SheetPageFormat::Full,
2040 ..SessionSheetPageParams::default()
2041 })?;
2042
2043 assert_eq!(page.sheet_name, "Sheet1");
2044 assert!(matches!(page.workbook_id, WorkbookId(ref id) if id == "session"));
2045 assert_eq!(page.next_start_row, Some(3));
2046 let header = page.header_row.as_ref().expect("header row");
2047 assert_eq!(header.row_index, 1);
2048 assert_eq!(page.rows.len(), 1);
2049 assert_eq!(page.rows[0].row_index, 2);
2050 assert_eq!(page.rows[0].cells[1].formula.as_deref(), Some("1+1"));
2051
2052 Ok(())
2053 }
2054
2055 #[test]
2056 fn sheet_page_compact_respects_columns_by_header() -> Result<()> {
2057 let bytes = workbook_bytes(|book| {
2058 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2059 sheet.get_cell_mut("A1").set_value("Name");
2060 sheet.get_cell_mut("B1").set_value("Score");
2061 sheet.get_cell_mut("C1").set_value("Ignore");
2062 sheet.get_cell_mut("A2").set_value("alpha");
2063 sheet.get_cell_mut("B2").set_value_number(99.0);
2064 sheet.get_cell_mut("C2").set_value("x");
2065 });
2066
2067 let session = WorkbookSession::from_bytes(bytes)?;
2068 let page = session.sheet_page(SessionSheetPageParams {
2069 sheet_name: "Sheet1".to_string(),
2070 start_row: 2,
2071 page_size: 1,
2072 columns_by_header: Some(vec!["score".to_string()]),
2073 format: SheetPageFormat::Compact,
2074 ..SessionSheetPageParams::default()
2075 })?;
2076
2077 let compact = page.compact.as_ref().expect("compact payload");
2078 assert_eq!(compact.headers, vec!["Row", "Score"]);
2079 assert_eq!(compact.rows.len(), 1);
2080 assert!(
2081 matches!(compact.rows[0][0], Some(CellValue::Number(n)) if (n - 2.0).abs() < f64::EPSILON)
2082 );
2083 assert!(
2084 matches!(compact.rows[0][1], Some(CellValue::Number(n)) if (n - 99.0).abs() < f64::EPSILON)
2085 );
2086
2087 Ok(())
2088 }
2089
2090 #[test]
2091 fn sheet_page_rejects_invalid_column_specs() -> Result<()> {
2092 let bytes = workbook_bytes(|book| {
2093 book.get_sheet_by_name_mut("Sheet1")
2094 .expect("sheet")
2095 .get_cell_mut("A1")
2096 .set_value("x");
2097 });
2098
2099 let session = WorkbookSession::from_bytes(bytes)?;
2100 let err = session
2101 .sheet_page(SessionSheetPageParams {
2102 sheet_name: "Sheet1".to_string(),
2103 columns: Some(vec!["1".to_string()]),
2104 ..SessionSheetPageParams::default()
2105 })
2106 .expect_err("invalid columns should error");
2107 assert!(err.to_string().contains("invalid column spec"));
2108
2109 Ok(())
2110 }
2111
2112 #[test]
2113 fn sheet_page_handles_large_start_rows_without_overflow() -> Result<()> {
2114 let bytes = workbook_bytes(|book| {
2115 book.get_sheet_by_name_mut("Sheet1")
2116 .expect("sheet")
2117 .get_cell_mut("A1")
2118 .set_value("x");
2119 });
2120
2121 let session = WorkbookSession::from_bytes(bytes)?;
2122 let page = session.sheet_page(SessionSheetPageParams {
2123 sheet_name: "Sheet1".to_string(),
2124 start_row: u32::MAX,
2125 page_size: 500,
2126 format: SheetPageFormat::ValuesOnly,
2127 ..SessionSheetPageParams::default()
2128 })?;
2129
2130 assert_eq!(page.next_start_row, None);
2131
2132 Ok(())
2133 }
2134
2135 #[test]
2136 fn describe_and_named_ranges_return_session_metadata() -> Result<()> {
2137 let bytes = workbook_bytes(|book| {
2138 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2139 sheet.get_cell_mut("A1").set_value("Revenue");
2140 sheet.get_cell_mut("A2").set_value_number(100.0);
2141 sheet
2142 .add_defined_name("TotalRevenue", "Sheet1!$A$2")
2143 .expect("defined name");
2144 });
2145
2146 let session = WorkbookSession::from_bytes(bytes)?;
2147 let desc = session.describe_workbook()?;
2148 assert!(matches!(desc.workbook_id, WorkbookId(ref id) if id == "session"));
2149 assert!(desc.sheet_count >= 1);
2150
2151 let named = session.named_ranges()?;
2152 assert!(matches!(named.workbook_id, WorkbookId(ref id) if id == "session"));
2153 assert!(named.items.iter().all(|item| !item.name.trim().is_empty()));
2154
2155 Ok(())
2156 }
2157
2158 #[test]
2159 fn sheet_overview_applies_region_and_header_limits() -> Result<()> {
2160 let bytes = workbook_bytes(|book| {
2161 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2162 sheet.get_cell_mut("A1").set_value("Name");
2163 sheet.get_cell_mut("B1").set_value("Score");
2164 sheet.get_cell_mut("A2").set_value("alpha");
2165 sheet.get_cell_mut("B2").set_value_number(10.0);
2166 sheet.get_cell_mut("A10").set_value("Name");
2167 sheet.get_cell_mut("B10").set_value("Score");
2168 sheet.get_cell_mut("A11").set_value("beta");
2169 sheet.get_cell_mut("B11").set_value_number(20.0);
2170 });
2171
2172 let session = WorkbookSession::from_bytes(bytes)?;
2173 let overview = session.sheet_overview(SessionSheetOverviewParams {
2174 sheet_name: "Sheet1".to_string(),
2175 max_regions: Some(1),
2176 max_headers: Some(1),
2177 include_headers: Some(true),
2178 })?;
2179
2180 assert_eq!(overview.sheet_name, "Sheet1");
2181 assert!(overview.detected_region_count >= overview.detected_regions.len() as u32);
2182 assert!(overview.detected_regions.len() <= 1);
2183
2184 Ok(())
2185 }
2186
2187 #[test]
2188 fn find_value_returns_matches_with_pagination() -> Result<()> {
2189 let bytes = workbook_bytes(|book| {
2190 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2191 sheet.get_cell_mut("A1").set_value("alpha");
2192 sheet.get_cell_mut("A2").set_value("alpha");
2193 sheet.get_cell_mut("A3").set_value("beta");
2194 });
2195
2196 let session = WorkbookSession::from_bytes(bytes)?;
2197 let result = session.find_value(SessionFindValueParams {
2198 query: "alpha".to_string(),
2199 limit: 1,
2200 offset: Some(0),
2201 ..SessionFindValueParams::default()
2202 })?;
2203
2204 assert_eq!(result.matches.len(), 1);
2205 assert_eq!(result.next_offset, Some(1));
2206
2207 Ok(())
2208 }
2209
2210 #[test]
2211 fn read_table_values_mode_returns_values_and_types() -> Result<()> {
2212 let bytes = workbook_bytes(|book| {
2213 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2214 sheet.get_cell_mut("A1").set_value("Name");
2215 sheet.get_cell_mut("B1").set_value("Score");
2216 sheet.get_cell_mut("A2").set_value("alpha");
2217 sheet.get_cell_mut("B2").set_value_number(42.0);
2218 });
2219
2220 let session = WorkbookSession::from_bytes(bytes)?;
2221 let table = session.read_table(SessionReadTableParams {
2222 sheet_name: Some("Sheet1".to_string()),
2223 range: Some("A1:B2".to_string()),
2224 format: TableOutputFormat::Values,
2225 include_headers: true,
2226 include_types: true,
2227 ..SessionReadTableParams::default()
2228 })?;
2229
2230 assert_eq!(table.sheet_name, "Sheet1");
2231 assert_eq!(table.headers, vec!["Name", "Score"]);
2232 assert!(table.rows.is_empty());
2233 assert_eq!(table.values.as_ref().map(Vec::len), Some(1));
2234 assert_eq!(table.types.as_ref().map(Vec::len), Some(1));
2235
2236 Ok(())
2237 }
2238
2239 #[test]
2240 fn read_table_csv_preserves_date_and_text_values() -> Result<()> {
2241 let bytes = workbook_bytes(|book| {
2242 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2243 sheet.get_cell_mut("A1").set_value("Date");
2244 sheet.get_cell_mut("B1").set_value("Note");
2245 sheet.get_cell_mut("A2").set_value_number(45292.0);
2246 sheet
2247 .get_style_mut("A2")
2248 .get_number_format_mut()
2249 .set_format_code("yyyy-mm-dd");
2250 sheet.get_cell_mut("B2").set_value("ok");
2251 });
2252
2253 let session = WorkbookSession::from_bytes(bytes)?;
2254 let table = session.read_table(SessionReadTableParams {
2255 sheet_name: Some("Sheet1".to_string()),
2256 range: Some("A1:B2".to_string()),
2257 format: TableOutputFormat::Csv,
2258 ..SessionReadTableParams::default()
2259 })?;
2260
2261 assert!(table.headers.is_empty());
2262 let csv = table.csv.expect("csv");
2263 assert!(csv.contains("-"));
2264 assert!(csv.contains("ok"));
2265
2266 Ok(())
2267 }
2268
2269 #[test]
2270 fn read_table_dedupes_duplicate_headers() -> Result<()> {
2271 let bytes = workbook_bytes(|book| {
2272 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2273 sheet.get_cell_mut("A1").set_value("Dup");
2274 sheet.get_cell_mut("B1").set_value("Dup");
2275 sheet.get_cell_mut("A2").set_value("v1");
2276 sheet.get_cell_mut("B2").set_value("v2");
2277 });
2278
2279 let session = WorkbookSession::from_bytes(bytes)?;
2280 let table = session.read_table(SessionReadTableParams {
2281 sheet_name: Some("Sheet1".to_string()),
2282 range: Some("A1:B2".to_string()),
2283 format: TableOutputFormat::Json,
2284 ..SessionReadTableParams::default()
2285 })?;
2286
2287 assert_eq!(table.headers, vec!["Dup", "Dup_2"]);
2288 let row = table.rows.first().expect("row");
2289 assert!(row.contains_key("Dup"));
2290 assert!(row.contains_key("Dup_2"));
2291
2292 Ok(())
2293 }
2294
2295 #[test]
2296 fn from_path_loads_workbook() -> Result<()> {
2297 let dir = tempdir()?;
2298 let path = dir.path().join("session-path.xlsx");
2299
2300 let bytes = workbook_bytes(|book| {
2301 book.get_sheet_by_name_mut("Sheet1")
2302 .expect("sheet")
2303 .get_cell_mut("C3")
2304 .set_value("path-load");
2305 });
2306 fs::write(&path, bytes)?;
2307
2308 let session = WorkbookSession::from_path(&path)?;
2309 let entries = session.range_values("Sheet1", "C3")?;
2310 let rows = entries[0].rows.as_ref().expect("rows");
2311 assert!(matches!(
2312 rows[0][0],
2313 Some(CellValue::Text(ref value)) if value == "path-load"
2314 ));
2315
2316 Ok(())
2317 }
2318
2319 #[test]
2320 fn define_name_workbook_scope_roundtrips() -> Result<()> {
2321 let bytes = workbook_bytes(|book| {
2322 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2323 sheet.get_cell_mut("A1").set_value("Revenue");
2324 sheet.get_cell_mut("A2").set_value_number(100.0);
2325 });
2326 let mut session = WorkbookSession::from_bytes(bytes)?;
2327
2328 let resp = session.define_name("TotalRev", "Sheet1!$A$2", None, None)?;
2329 assert_eq!(resp.name, "TotalRev");
2330 assert_eq!(resp.scope_kind, crate::model::NamedRangeScope::Workbook);
2331 assert!(resp.scope_sheet_name.is_none());
2332
2333 let export = session.to_bytes()?;
2335 let session2 = WorkbookSession::from_bytes(export)?;
2336 let named = session2.named_ranges()?;
2337 assert!(
2338 named.items.iter().any(|item| item.name == "TotalRev"),
2339 "TotalRev should be visible after roundtrip"
2340 );
2341
2342 Ok(())
2343 }
2344
2345 #[test]
2346 fn define_name_sheet_scope_roundtrips() -> Result<()> {
2347 let bytes = workbook_bytes(|book| {
2348 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2349 sheet.get_cell_mut("A1").set_value("Revenue");
2350 });
2351 let mut session = WorkbookSession::from_bytes(bytes)?;
2352
2353 let resp =
2354 session.define_name("LocalName", "Sheet1!$A$1", Some("sheet"), Some("Sheet1"))?;
2355 assert_eq!(resp.name, "LocalName");
2356 assert_eq!(resp.scope_kind, crate::model::NamedRangeScope::Sheet);
2357 assert_eq!(resp.scope_sheet_name.as_deref(), Some("Sheet1"));
2358
2359 Ok(())
2360 }
2361
2362 #[test]
2363 fn update_name_changes_refers_to() -> Result<()> {
2364 let bytes = workbook_bytes(|book| {
2365 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2366 sheet.get_cell_mut("A1").set_value("Revenue");
2367 sheet
2368 .add_defined_name("MyName", "Sheet1!$A$1")
2369 .expect("defined name");
2370 });
2371 let mut session = WorkbookSession::from_bytes(bytes)?;
2372
2373 let resp = session.update_name("MyName", Some("Sheet1!$A$1:$B$5"), None, None)?;
2374 assert_eq!(resp.name, "MyName");
2375 assert!(resp.previous_refers_to.is_some());
2376
2377 Ok(())
2378 }
2379
2380 #[test]
2381 fn delete_name_removes_defined_name() -> Result<()> {
2382 let bytes = workbook_bytes(|book| {
2383 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2384 sheet.get_cell_mut("A1").set_value("Revenue");
2385 sheet
2386 .add_defined_name("ToDelete", "Sheet1!$A$1")
2387 .expect("defined name");
2388 });
2389 let mut session = WorkbookSession::from_bytes(bytes)?;
2390
2391 let named_before = session.named_ranges()?;
2392 assert!(named_before.items.iter().any(|i| i.name == "ToDelete"));
2393
2394 let resp = session.delete_name("ToDelete", None, None)?;
2395 assert!(resp.deleted);
2396
2397 let named_after = session.named_ranges()?;
2398 assert!(!named_after.items.iter().any(|i| i.name == "ToDelete"));
2399
2400 Ok(())
2401 }
2402
2403 #[test]
2404 fn delete_name_not_found_returns_error() -> Result<()> {
2405 let bytes = workbook_bytes(|_| {});
2406 let mut session = WorkbookSession::from_bytes(bytes)?;
2407
2408 let result = session.delete_name("NonExistent", None, None);
2409 assert!(result.is_err());
2410 assert!(result.unwrap_err().to_string().contains("not found"));
2411
2412 Ok(())
2413 }
2414
2415 #[test]
2416 fn named_ranges_scope_metadata_populated() -> Result<()> {
2417 let bytes = workbook_bytes(|book| {
2418 let sheet = book.get_sheet_by_name_mut("Sheet1").expect("sheet");
2419 sheet.get_cell_mut("A1").set_value("Revenue");
2420 sheet
2421 .add_defined_name("SheetLocal", "Sheet1!$A$1")
2422 .expect("defined name");
2423 });
2424 let session = WorkbookSession::from_bytes(bytes)?;
2425 let named = session.named_ranges()?;
2426
2427 for item in &named.items {
2428 assert!(
2429 item.scope_kind.is_some(),
2430 "scope_kind should be populated for item '{}'",
2431 item.name
2432 );
2433 }
2434
2435 Ok(())
2436 }
2437}