Skip to main content

spreadsheet_kit/core/
session.rs

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
21/// Surface-agnostic in-memory workbook session.
22///
23/// This API avoids workbook IDs, fork handles, and MCP-specific wiring so it can
24/// be reused by CLI, SDK, or WASM bindings.
25pub struct WorkbookSession {
26    spreadsheet: Spreadsheet,
27}
28
29impl WorkbookSession {
30    /// Open a workbook session from raw XLSX bytes.
31    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    /// Open a workbook session from a filesystem path.
40    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    /// Return sheet names in workbook order.
48    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    /// Return workbook-level descriptor for this in-memory session.
57    pub fn describe_workbook(&self) -> Result<WorkbookDescription> {
58        let workbook = self.as_workbook_context()?;
59        Ok(workbook.describe())
60    }
61
62    /// Return workbook defined names and table descriptors.
63    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    /// Define a new named range.
73    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    /// Update an existing named range.
157    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        // Workbook-level.
188        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        // Sheet-level.
206        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    /// Delete a named range.
257    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        // Workbook-level.
284        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        // Sheet-level.
294        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    /// Return overview/classification information for a sheet.
330    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(&params.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    /// Search for values in one sheet or across all sheets.
394    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    /// Read a rectangular table snapshot from a sheet.
472    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    /// Read one or more A1 ranges from a sheet.
616    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    /// Read a page-oriented snapshot from a sheet.
657    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(&params.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    /// Export a range as grid payload (value/formula/style patch surface).
701    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    /// Apply a transform batch in-session.
778    ///
779    /// For this extraction pass we support `write_matrix` operations.
780    pub fn apply_ops(&mut self, ops: &[SessionTransformOp]) -> Result<SessionApplySummary> {
781        // Validate all operations first so failures are atomic for this batch.
782        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    /// Convenience wrapper for a single `write_matrix` operation.
841    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    /// Serialize the current in-memory workbook state back to XLSX bytes.
858    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    /// Write current workbook state to a temporary XLSX file.
866    ///
867    /// The caller owns the `NamedTempFile` and must keep it alive for as long as
868    /// the path is needed (e.g. during apply-to-file round-trips).
869    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    /// Reload in-memory workbook state from an on-disk XLSX file.
881    ///
882    /// Used after an external `apply_*_to_file()` function has mutated the file
883    /// to bring the session back in sync.
884    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    /// Serialize and consume the current in-memory workbook state.
895    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    /// Look up a sheet by name, returning `Some` if found.
944    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        // Roundtrip: verify the name is visible after re-read.
2334        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}