spreadsheet_read_mcp/tools/
mod.rs

1pub mod filters;
2
3use crate::analysis::{formula::FormulaGraph, stats};
4use crate::model::*;
5use crate::state::AppState;
6use crate::workbook::{WorkbookContext, cell_to_value};
7use anyhow::{Result, anyhow};
8use schemars::JsonSchema;
9use serde::Deserialize;
10use std::cmp::Ordering;
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14const DEFAULT_TRACE_PAGE_SIZE: usize = 20;
15const TRACE_PAGE_MIN: usize = 5;
16const TRACE_PAGE_MAX: usize = 200;
17const TRACE_RANGE_THRESHOLD: usize = 4;
18const TRACE_RANGE_HIGHLIGHT_LIMIT: usize = 3;
19const TRACE_GROUP_HIGHLIGHT_LIMIT: usize = 3;
20const TRACE_CELL_HIGHLIGHT_LIMIT: usize = 5;
21const TRACE_RANGE_VALUE_SAMPLES: usize = 3;
22const TRACE_RANGE_FORMULA_SAMPLES: usize = 2;
23const TRACE_GROUP_SAMPLE_LIMIT: usize = 5;
24
25pub async fn list_workbooks(
26    state: Arc<AppState>,
27    params: ListWorkbooksParams,
28) -> Result<WorkbookListResponse> {
29    let filter = params.into_filter()?;
30    state.list_workbooks(filter)
31}
32
33pub async fn describe_workbook(
34    state: Arc<AppState>,
35    params: DescribeWorkbookParams,
36) -> Result<WorkbookDescription> {
37    let workbook = state.open_workbook(&params.workbook_id).await?;
38    let desc = workbook.describe();
39    Ok(desc)
40}
41
42#[derive(Debug, Deserialize, JsonSchema)]
43pub struct ListWorkbooksParams {
44    pub slug_prefix: Option<String>,
45    pub folder: Option<String>,
46    pub path_glob: Option<String>,
47}
48
49impl ListWorkbooksParams {
50    fn into_filter(self) -> Result<filters::WorkbookFilter> {
51        filters::WorkbookFilter::new(self.slug_prefix, self.folder, self.path_glob)
52    }
53}
54
55#[derive(Debug, Deserialize, JsonSchema)]
56pub struct DescribeWorkbookParams {
57    pub workbook_id: WorkbookId,
58}
59
60#[derive(Debug, Deserialize, JsonSchema)]
61pub struct ListSheetsParams {
62    pub workbook_id: WorkbookId,
63}
64
65pub async fn list_sheets(
66    state: Arc<AppState>,
67    params: ListSheetsParams,
68) -> Result<SheetListResponse> {
69    let workbook = state.open_workbook(&params.workbook_id).await?;
70    let summaries = workbook.list_summaries()?;
71    let response = SheetListResponse {
72        workbook_id: workbook.id.clone(),
73        workbook_short_id: workbook.short_id.clone(),
74        sheets: summaries,
75    };
76    Ok(response)
77}
78
79#[derive(Debug, Deserialize, JsonSchema)]
80pub struct SheetOverviewParams {
81    pub workbook_id: WorkbookId,
82    pub sheet_name: String,
83}
84
85pub async fn sheet_overview(
86    state: Arc<AppState>,
87    params: SheetOverviewParams,
88) -> Result<SheetOverviewResponse> {
89    let workbook = state.open_workbook(&params.workbook_id).await?;
90    let overview = workbook.sheet_overview(&params.sheet_name)?;
91    Ok(overview)
92}
93
94fn default_start_row() -> u32 {
95    1
96}
97
98fn default_page_size() -> u32 {
99    50
100}
101
102fn default_include_formulas() -> bool {
103    true
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct SheetPageParams {
108    pub workbook_id: WorkbookId,
109    pub sheet_name: String,
110    #[serde(default = "default_start_row")]
111    pub start_row: u32,
112    #[serde(default = "default_page_size")]
113    pub page_size: u32,
114    #[serde(default)]
115    pub columns: Option<Vec<String>>,
116    #[serde(default = "default_include_formulas")]
117    pub include_formulas: bool,
118    #[serde(default)]
119    pub include_styles: bool,
120}
121
122pub async fn sheet_page(
123    state: Arc<AppState>,
124    params: SheetPageParams,
125) -> Result<SheetPageResponse> {
126    if params.page_size == 0 {
127        return Err(anyhow!("page_size must be greater than zero"));
128    }
129
130    let workbook = state.open_workbook(&params.workbook_id).await?;
131    let metrics = workbook.get_sheet_metrics(&params.sheet_name)?;
132
133    let start_row = params.start_row.max(1);
134    let page_size = params.page_size.min(500);
135    let include_formulas = params.include_formulas;
136    let include_styles = params.include_styles;
137    let columns = params.columns.clone();
138
139    let page = workbook.with_sheet(&params.sheet_name, |sheet| {
140        build_page(
141            sheet,
142            start_row,
143            page_size,
144            columns.clone(),
145            include_formulas,
146            include_styles,
147        )
148    })?;
149
150    let has_more = page.end_row < metrics.metrics.row_count;
151    let next_start_row = if has_more {
152        Some(page.end_row + 1)
153    } else {
154        None
155    };
156
157    let response = SheetPageResponse {
158        workbook_id: workbook.id.clone(),
159        workbook_short_id: workbook.short_id.clone(),
160        sheet_name: params.sheet_name,
161        rows: page.rows,
162        has_more,
163        next_start_row,
164        header_row: page.header,
165    };
166    Ok(response)
167}
168
169#[derive(Debug, Deserialize, JsonSchema)]
170pub struct SheetFormulaMapParams {
171    pub workbook_id: WorkbookId,
172    pub sheet_name: String,
173    pub range: Option<String>,
174    #[serde(default)]
175    pub expand: bool,
176}
177
178pub async fn sheet_formula_map(
179    state: Arc<AppState>,
180    params: SheetFormulaMapParams,
181) -> Result<SheetFormulaMapResponse> {
182    let workbook = state.open_workbook(&params.workbook_id).await?;
183    let graph = workbook.formula_graph(&params.sheet_name)?;
184    let mut groups = Vec::new();
185    let mut truncated = false;
186
187    for mut group in graph.groups() {
188        if let Some(range) = &params.range {
189            group.addresses.retain(|addr| address_in_range(addr, range));
190            if group.addresses.is_empty() {
191                continue;
192            }
193        }
194        if !params.expand && group.addresses.len() > 15 {
195            group.addresses.truncate(15);
196            truncated = true;
197        }
198        groups.push(group);
199    }
200
201    let response = SheetFormulaMapResponse {
202        workbook_id: workbook.id.clone(),
203        workbook_short_id: workbook.short_id.clone(),
204        sheet_name: params.sheet_name.clone(),
205        groups,
206        truncated,
207    };
208    Ok(response)
209}
210
211#[derive(Debug, Deserialize, JsonSchema)]
212pub struct FormulaTraceParams {
213    pub workbook_id: WorkbookId,
214    pub sheet_name: String,
215    pub cell_address: String,
216    pub direction: TraceDirection,
217    pub depth: Option<u32>,
218    pub limit: Option<u32>,
219    #[serde(default)]
220    pub page_size: Option<usize>,
221    #[serde(default)]
222    pub cursor: Option<TraceCursor>,
223}
224
225pub async fn formula_trace(
226    state: Arc<AppState>,
227    params: FormulaTraceParams,
228) -> Result<FormulaTraceResponse> {
229    let workbook = state.open_workbook(&params.workbook_id).await?;
230    let graph = workbook.formula_graph(&params.sheet_name)?;
231    let formula_lookup = build_formula_lookup(&graph);
232    let depth = params.depth.unwrap_or(3).clamp(1, 5);
233    let page_size = params
234        .page_size
235        .or_else(|| params.limit.map(|v| v as usize))
236        .unwrap_or(DEFAULT_TRACE_PAGE_SIZE)
237        .clamp(TRACE_PAGE_MIN, TRACE_PAGE_MAX);
238
239    let origin = params.cell_address.to_uppercase();
240    let config = TraceConfig {
241        direction: &params.direction,
242        origin: &origin,
243        sheet_name: &params.sheet_name,
244        depth_limit: depth,
245        page_size,
246    };
247    let (layers, next_cursor, notes) = build_trace_layers(
248        &workbook,
249        &graph,
250        &formula_lookup,
251        &config,
252        params.cursor.clone(),
253    )?;
254
255    let response = FormulaTraceResponse {
256        workbook_id: workbook.id.clone(),
257        workbook_short_id: workbook.short_id.clone(),
258        sheet_name: params.sheet_name.clone(),
259        origin,
260        direction: params.direction.clone(),
261        layers,
262        next_cursor,
263        notes,
264    };
265    Ok(response)
266}
267
268#[derive(Debug, Deserialize, JsonSchema)]
269pub struct NamedRangesParams {
270    pub workbook_id: WorkbookId,
271    pub sheet_name: Option<String>,
272    pub name_prefix: Option<String>,
273}
274
275pub async fn named_ranges(
276    state: Arc<AppState>,
277    params: NamedRangesParams,
278) -> Result<NamedRangesResponse> {
279    let workbook = state.open_workbook(&params.workbook_id).await?;
280    let mut items = workbook.named_items()?;
281
282    if let Some(sheet_filter) = &params.sheet_name {
283        items.retain(|item| {
284            item.sheet_name
285                .as_ref()
286                .map(|name| name.eq_ignore_ascii_case(sheet_filter))
287                .unwrap_or(false)
288        });
289    }
290    if let Some(prefix) = &params.name_prefix {
291        let prefix_lower = prefix.to_ascii_lowercase();
292        items.retain(|item| item.name.to_ascii_lowercase().starts_with(&prefix_lower));
293    }
294
295    let response = NamedRangesResponse {
296        workbook_id: workbook.id.clone(),
297        workbook_short_id: workbook.short_id.clone(),
298        items,
299    };
300    Ok(response)
301}
302
303struct PageBuildResult {
304    rows: Vec<RowSnapshot>,
305    header: Option<RowSnapshot>,
306    end_row: u32,
307}
308
309fn build_page(
310    sheet: &umya_spreadsheet::Worksheet,
311    start_row: u32,
312    page_size: u32,
313    columns: Option<Vec<String>>,
314    include_formulas: bool,
315    include_styles: bool,
316) -> PageBuildResult {
317    let max_col = sheet.get_highest_column();
318    let end_row = (start_row + page_size - 1).min(sheet.get_highest_row().max(start_row));
319    let column_indices = resolve_columns(columns.as_ref(), max_col);
320
321    let header = build_row_snapshot(sheet, 1, &column_indices, include_formulas, include_styles);
322
323    let mut rows = Vec::new();
324    for row_idx in start_row..=end_row {
325        rows.push(build_row_snapshot(
326            sheet,
327            row_idx,
328            &column_indices,
329            include_formulas,
330            include_styles,
331        ));
332    }
333
334    PageBuildResult {
335        rows,
336        header: Some(header),
337        end_row,
338    }
339}
340
341fn build_row_snapshot(
342    sheet: &umya_spreadsheet::Worksheet,
343    row_index: u32,
344    columns: &[u32],
345    include_formulas: bool,
346    include_styles: bool,
347) -> RowSnapshot {
348    let mut cells = Vec::new();
349    for &col in columns {
350        if let Some(cell) = sheet.get_cell((row_index, col)) {
351            cells.push(build_cell_snapshot(cell, include_formulas, include_styles));
352        } else {
353            let address = crate::utils::cell_address(col, row_index);
354            cells.push(CellSnapshot {
355                address,
356                value: None,
357                formula: None,
358                cached_value: None,
359                number_format: None,
360                style_tags: Vec::new(),
361                notes: Vec::new(),
362            });
363        }
364    }
365
366    RowSnapshot { row_index, cells }
367}
368
369fn build_cell_snapshot(
370    cell: &umya_spreadsheet::Cell,
371    include_formulas: bool,
372    include_styles: bool,
373) -> CellSnapshot {
374    let address = cell.get_coordinate().get_coordinate();
375    let value = crate::workbook::cell_to_value(cell);
376    let formula = if include_formulas && cell.is_formula() {
377        Some(cell.get_formula().to_string())
378    } else {
379        None
380    };
381    let cached_value = if cell.is_formula() {
382        value.clone()
383    } else {
384        None
385    };
386    let number_format = if include_styles {
387        cell.get_style()
388            .get_number_format()
389            .map(|fmt| fmt.get_format_code().to_string())
390    } else {
391        None
392    };
393    let style_tags = if include_styles {
394        crate::analysis::style::tag_cell(cell)
395            .map(|(_, tagging)| tagging.tags)
396            .unwrap_or_default()
397    } else {
398        Vec::new()
399    };
400
401    CellSnapshot {
402        address,
403        value,
404        formula,
405        cached_value,
406        number_format,
407        style_tags,
408        notes: Vec::new(),
409    }
410}
411
412fn resolve_columns(columns: Option<&Vec<String>>, max_column: u32) -> Vec<u32> {
413    use std::collections::BTreeSet;
414    use umya_spreadsheet::helper::coordinate::column_index_from_string;
415
416    let mut indices = BTreeSet::new();
417    if let Some(specs) = columns {
418        for spec in specs {
419            if let Some((start, end)) = spec.split_once(':') {
420                let start_idx = column_index_from_string(start);
421                let end_idx = column_index_from_string(end);
422                let (min_idx, max_idx) = if start_idx <= end_idx {
423                    (start_idx, end_idx)
424                } else {
425                    (end_idx, start_idx)
426                };
427                for idx in min_idx..=max_idx {
428                    indices.insert(idx);
429                }
430            } else {
431                indices.insert(column_index_from_string(spec));
432            }
433        }
434    } else {
435        for idx in 1..=max_column.max(1) {
436            indices.insert(idx);
437        }
438    }
439
440    indices.into_iter().collect()
441}
442fn default_stats_sample() -> usize {
443    500
444}
445
446#[derive(Debug, Deserialize, JsonSchema)]
447pub struct SheetStatisticsParams {
448    pub workbook_id: WorkbookId,
449    pub sheet_name: String,
450    #[serde(default)]
451    pub sample_rows: Option<usize>,
452}
453
454pub async fn sheet_statistics(
455    state: Arc<AppState>,
456    params: SheetStatisticsParams,
457) -> Result<SheetStatisticsResponse> {
458    let workbook = state.open_workbook(&params.workbook_id).await?;
459    let sheet_metrics = workbook.get_sheet_metrics(&params.sheet_name)?;
460    let sample_rows = params.sample_rows.unwrap_or_else(default_stats_sample);
461    let stats = workbook.with_sheet(&params.sheet_name, |sheet| {
462        stats::compute_sheet_statistics(sheet, sample_rows)
463    })?;
464    let response = SheetStatisticsResponse {
465        workbook_id: workbook.id.clone(),
466        workbook_short_id: workbook.short_id.clone(),
467        sheet_name: params.sheet_name,
468        row_count: sheet_metrics.metrics.row_count,
469        column_count: sheet_metrics.metrics.column_count,
470        density: stats.density,
471        numeric_columns: stats.numeric_columns,
472        text_columns: stats.text_columns,
473        null_counts: stats.null_counts,
474        duplicate_warnings: stats.duplicate_warnings,
475    };
476    Ok(response)
477}
478
479fn address_in_range(address: &str, range: &str) -> bool {
480    parse_range(range).is_none_or(|((start_col, start_row), (end_col, end_row))| {
481        if let Some((col, row)) = parse_address(address) {
482            col >= start_col && col <= end_col && row >= start_row && row <= end_row
483        } else {
484            false
485        }
486    })
487}
488
489fn parse_range(range: &str) -> Option<((u32, u32), (u32, u32))> {
490    let mut parts = range.split(':');
491    let start = parts.next()?;
492    let end = parts.next().unwrap_or(start);
493    let start_idx = parse_address(start)?;
494    let end_idx = parse_address(end)?;
495    Some((
496        (start_idx.0.min(end_idx.0), start_idx.1.min(end_idx.1)),
497        (start_idx.0.max(end_idx.0), start_idx.1.max(end_idx.1)),
498    ))
499}
500
501fn parse_address(address: &str) -> Option<(u32, u32)> {
502    use umya_spreadsheet::helper::coordinate::index_from_coordinate;
503    let (col, row, _, _) = index_from_coordinate(address);
504    match (col, row) {
505        (Some(c), Some(r)) => Some((c, r)),
506        _ => None,
507    }
508}
509
510#[derive(Debug, Deserialize, JsonSchema)]
511pub struct FindFormulaParams {
512    pub workbook_id: WorkbookId,
513    pub query: String,
514    pub sheet_name: Option<String>,
515    #[serde(default)]
516    pub case_sensitive: bool,
517}
518
519pub async fn find_formula(
520    state: Arc<AppState>,
521    params: FindFormulaParams,
522) -> Result<FindFormulaResponse> {
523    let workbook = state.open_workbook(&params.workbook_id).await?;
524    let query = if params.case_sensitive {
525        params.query.clone()
526    } else {
527        params.query.to_ascii_lowercase()
528    };
529    let mut matches = Vec::new();
530
531    let sheet_names: Vec<String> = if let Some(sheet) = &params.sheet_name {
532        vec![sheet.clone()]
533    } else {
534        workbook.sheet_names()
535    };
536
537    for sheet_name in sheet_names {
538        let sheet_matches = workbook.with_sheet(&sheet_name, |sheet| {
539            collect_formula_matches(sheet, &sheet_name, &query, params.case_sensitive)
540        })?;
541        matches.extend(sheet_matches);
542    }
543
544    let response = FindFormulaResponse {
545        workbook_id: workbook.id.clone(),
546        workbook_short_id: workbook.short_id.clone(),
547        matches,
548    };
549    Ok(response)
550}
551
552#[derive(Debug, Deserialize, JsonSchema)]
553pub struct ScanVolatilesParams {
554    pub workbook_id: WorkbookId,
555    pub sheet_name: Option<String>,
556}
557
558pub async fn scan_volatiles(
559    state: Arc<AppState>,
560    params: ScanVolatilesParams,
561) -> Result<VolatileScanResponse> {
562    let workbook = state.open_workbook(&params.workbook_id).await?;
563    let target_sheets: Vec<String> = if let Some(sheet) = &params.sheet_name {
564        vec![sheet.clone()]
565    } else {
566        workbook.sheet_names()
567    };
568
569    let mut items = Vec::new();
570    let mut truncated = false;
571
572    for sheet_name in target_sheets {
573        let graph = workbook.formula_graph(&sheet_name)?;
574        for group in graph.groups() {
575            if !group.is_volatile {
576                continue;
577            }
578            for address in group.addresses.iter().take(50) {
579                items.push(VolatileScanEntry {
580                    address: address.clone(),
581                    sheet_name: sheet_name.clone(),
582                    function: "volatile".to_string(),
583                    note: Some(group.formula.clone()),
584                });
585            }
586            if group.addresses.len() > 50 {
587                truncated = true;
588            }
589        }
590    }
591
592    let response = VolatileScanResponse {
593        workbook_id: workbook.id.clone(),
594        workbook_short_id: workbook.short_id.clone(),
595        items,
596        truncated,
597    };
598    Ok(response)
599}
600
601#[derive(Debug, Deserialize, JsonSchema)]
602pub struct SheetStylesParams {
603    pub workbook_id: WorkbookId,
604    pub sheet_name: String,
605}
606
607pub async fn sheet_styles(
608    state: Arc<AppState>,
609    params: SheetStylesParams,
610) -> Result<SheetStylesResponse> {
611    let workbook = state.open_workbook(&params.workbook_id).await?;
612    let entry = workbook.get_sheet_metrics(&params.sheet_name)?;
613
614    let styles = entry
615        .metrics
616        .style_map
617        .iter()
618        .map(|(style_id, usage)| StyleSummary {
619            style_id: style_id.clone(),
620            occurrences: usage.occurrences,
621            tags: usage.tags.clone(),
622            example_cells: usage.example_cells.clone(),
623        })
624        .collect();
625
626    let response = SheetStylesResponse {
627        workbook_id: workbook.id.clone(),
628        workbook_short_id: workbook.short_id.clone(),
629        sheet_name: params.sheet_name.clone(),
630        styles,
631        conditional_rules: Vec::new(),
632    };
633    Ok(response)
634}
635
636#[derive(Debug, Deserialize, JsonSchema)]
637pub struct ManifestStubParams {
638    pub workbook_id: WorkbookId,
639    pub sheet_filter: Option<String>,
640}
641
642pub async fn get_manifest_stub(
643    state: Arc<AppState>,
644    params: ManifestStubParams,
645) -> Result<ManifestStubResponse> {
646    let workbook = state.open_workbook(&params.workbook_id).await?;
647    let mut summaries = workbook.list_summaries()?;
648
649    if let Some(filter) = &params.sheet_filter {
650        summaries.retain(|summary| summary.name.eq_ignore_ascii_case(filter));
651    }
652
653    let sheets = summaries
654        .into_iter()
655        .map(|summary| ManifestSheetStub {
656            sheet_name: summary.name.clone(),
657            classification: summary.classification.clone(),
658            candidate_expectations: vec![format!(
659                "Review {} sheet for expectation candidates",
660                format!("{:?}", summary.classification).to_ascii_lowercase()
661            )],
662            notes: summary.style_tags,
663        })
664        .collect();
665
666    let response = ManifestStubResponse {
667        workbook_id: workbook.id.clone(),
668        workbook_short_id: workbook.short_id.clone(),
669        slug: workbook.slug.clone(),
670        sheets,
671    };
672    Ok(response)
673}
674
675#[derive(Debug, Deserialize, JsonSchema)]
676pub struct CloseWorkbookParams {
677    pub workbook_id: WorkbookId,
678}
679
680pub async fn close_workbook(
681    state: Arc<AppState>,
682    params: CloseWorkbookParams,
683) -> Result<CloseWorkbookResponse> {
684    state.close_workbook(&params.workbook_id)?;
685    Ok(CloseWorkbookResponse {
686        workbook_id: params.workbook_id.clone(),
687        message: format!("workbook {} evicted", params.workbook_id.as_str()),
688    })
689}
690fn collect_formula_matches(
691    sheet: &umya_spreadsheet::Worksheet,
692    sheet_name: &str,
693    query: &str,
694    case_sensitive: bool,
695) -> Vec<FindFormulaMatch> {
696    use crate::workbook::cell_to_value;
697
698    let mut results = Vec::new();
699    for cell in sheet.get_cell_collection() {
700        if !cell.is_formula() {
701            continue;
702        }
703        let formula = cell.get_formula();
704        let haystack = if case_sensitive {
705            formula.to_string()
706        } else {
707            formula.to_ascii_lowercase()
708        };
709        if !haystack.contains(query) {
710            continue;
711        }
712        let coord = cell.get_coordinate();
713        let column = *coord.get_col_num();
714        let row = *coord.get_row_num();
715        let columns = vec![column];
716        let context_row = build_row_snapshot(sheet, row, &columns, true, false);
717        let header_row = build_row_snapshot(sheet, 1, &columns, false, false);
718
719        results.push(FindFormulaMatch {
720            address: coord.get_coordinate(),
721            sheet_name: sheet_name.to_string(),
722            formula: formula.to_string(),
723            cached_value: if cell.is_formula() {
724                cell_to_value(cell)
725            } else {
726                None
727            },
728            context: vec![header_row, context_row],
729        });
730    }
731    results
732}
733
734#[derive(Clone)]
735struct TraceFormulaInfo {
736    fingerprint: String,
737    formula: String,
738}
739
740#[derive(Clone)]
741struct TraceEdgeRaw {
742    from: String,
743    to: String,
744    neighbor: String,
745}
746
747#[derive(Clone)]
748struct LayerLinks {
749    depth: u32,
750    edges: Vec<TraceEdgeRaw>,
751}
752
753#[derive(Clone)]
754struct NeighborDetail {
755    address: String,
756    column: Option<u32>,
757    row: Option<u32>,
758    kind: TraceCellKind,
759    value: Option<CellValue>,
760    formula: Option<String>,
761    fingerprint: Option<String>,
762    external: bool,
763}
764
765fn build_formula_lookup(graph: &FormulaGraph) -> HashMap<String, TraceFormulaInfo> {
766    let mut map = HashMap::new();
767    for group in graph.groups() {
768        for address in group.addresses.clone() {
769            map.insert(
770                address.to_ascii_uppercase(),
771                TraceFormulaInfo {
772                    fingerprint: group.fingerprint.clone(),
773                    formula: group.formula.clone(),
774                },
775            );
776        }
777    }
778    map
779}
780
781struct TraceConfig<'a> {
782    direction: &'a TraceDirection,
783    origin: &'a str,
784    sheet_name: &'a str,
785    depth_limit: u32,
786    page_size: usize,
787}
788
789fn build_trace_layers(
790    workbook: &WorkbookContext,
791    graph: &FormulaGraph,
792    formula_lookup: &HashMap<String, TraceFormulaInfo>,
793    config: &TraceConfig<'_>,
794    cursor: Option<TraceCursor>,
795) -> Result<(Vec<TraceLayer>, Option<TraceCursor>, Vec<String>)> {
796    let layer_links = collect_layer_links(graph, config.direction, config.origin, config.depth_limit);
797    let mut layers = Vec::new();
798    let mut next_cursor = None;
799    let mut notes = Vec::new();
800    let focus_depth = cursor.as_ref().map(|c| c.depth);
801
802    for layer in layer_links {
803        let produce_edges = focus_depth.is_none_or(|depth| depth == layer.depth);
804        let offset = cursor
805            .as_ref()
806            .filter(|c| c.depth == layer.depth)
807            .map(|c| c.offset)
808            .unwrap_or(0);
809
810        let mut node_set: HashSet<String> = HashSet::new();
811        for edge in &layer.edges {
812            node_set.insert(edge.neighbor.clone());
813        }
814        let mut nodes: Vec<String> = node_set.into_iter().collect();
815        nodes.sort_by(|a, b| compare_addresses(a, b));
816
817        let details = workbook.with_sheet(config.sheet_name, |sheet| {
818            collect_neighbor_details(sheet, config.sheet_name, &nodes, formula_lookup)
819        })?;
820        let total_nodes = details.len();
821        let start = offset.min(total_nodes);
822        let end = if produce_edges {
823            (start + config.page_size).min(total_nodes)
824        } else {
825            start
826        };
827        let selected_slice = if produce_edges {
828            &details[start..end]
829        } else {
830            &details[0..0]
831        };
832        let selected_addresses: HashSet<String> = selected_slice
833            .iter()
834            .map(|detail| detail.address.clone())
835            .collect();
836
837        let summary = build_layer_summary(&details);
838        let range_highlights = build_range_highlights(&details);
839        let group_highlights = build_formula_group_highlights(&details);
840        let notable_cells = build_notable_cells(&details, &range_highlights, &group_highlights);
841
842        let highlights = TraceLayerHighlights {
843            top_ranges: range_highlights.clone(),
844            top_formula_groups: group_highlights.clone(),
845            notable_cells,
846        };
847
848        let edges = if produce_edges {
849            build_edges_for_layer(&layer.edges, &selected_addresses, formula_lookup)
850        } else {
851            Vec::new()
852        };
853
854        let has_more = produce_edges && end < total_nodes;
855        if has_more && next_cursor.is_none() {
856            next_cursor = Some(TraceCursor {
857                depth: layer.depth,
858                offset: end,
859            });
860        }
861        if has_more {
862            notes.push(format!(
863                "Layer {} truncated at {} of {} nodes; supply cursor.depth={} and cursor.offset={} to continue",
864                layer.depth, end, total_nodes, layer.depth, end
865            ));
866        }
867
868        layers.push(TraceLayer {
869            depth: layer.depth,
870            summary,
871            highlights,
872            edges,
873            has_more,
874        });
875    }
876
877    Ok((layers, next_cursor, notes))
878}
879
880fn collect_layer_links(
881    graph: &FormulaGraph,
882    direction: &TraceDirection,
883    origin: &str,
884    depth_limit: u32,
885) -> Vec<LayerLinks> {
886    let mut visited: HashSet<String> = HashSet::new();
887    visited.insert(origin.to_string());
888    let mut frontier = vec![origin.to_string()];
889    let mut layers = Vec::new();
890
891    for depth in 1..=depth_limit {
892        let mut next_frontier_set: HashSet<String> = HashSet::new();
893        let mut edges = Vec::new();
894
895        for cell in &frontier {
896            let neighbors = match direction {
897                TraceDirection::Precedents => graph.precedents(cell),
898                TraceDirection::Dependents => graph.dependents(cell),
899            };
900
901            for neighbor in neighbors {
902                let neighbor_upper = neighbor.to_ascii_uppercase();
903                let edge = match direction {
904                    TraceDirection::Precedents => TraceEdgeRaw {
905                        from: cell.clone(),
906                        to: neighbor_upper.clone(),
907                        neighbor: neighbor_upper.clone(),
908                    },
909                    TraceDirection::Dependents => TraceEdgeRaw {
910                        from: neighbor_upper.clone(),
911                        to: cell.clone(),
912                        neighbor: neighbor_upper.clone(),
913                    },
914                };
915                edges.push(edge);
916                if visited.insert(neighbor_upper.clone()) {
917                    next_frontier_set.insert(neighbor_upper);
918                }
919            }
920        }
921
922        if edges.is_empty() {
923            break;
924        }
925
926        layers.push(LayerLinks { depth, edges });
927        if next_frontier_set.is_empty() {
928            break;
929        }
930        let mut next_frontier: Vec<String> = next_frontier_set.into_iter().collect();
931        next_frontier.sort();
932        frontier = next_frontier;
933    }
934
935    layers
936}
937
938fn collect_neighbor_details(
939    sheet: &umya_spreadsheet::Worksheet,
940    current_sheet: &str,
941    addresses: &[String],
942    formula_lookup: &HashMap<String, TraceFormulaInfo>,
943) -> Vec<NeighborDetail> {
944    let mut details = Vec::new();
945    for address in addresses {
946        let (sheet_part, cell_part) = split_sheet_and_cell(address);
947        let normalized_sheet = sheet_part
948            .as_ref()
949            .map(|s| clean_sheet_name(s).to_ascii_lowercase());
950        let is_external = normalized_sheet
951            .as_ref()
952            .map(|s| !s.eq_ignore_ascii_case(current_sheet))
953            .unwrap_or(false);
954
955        let Some(cell_ref) = cell_part else {
956            details.push(NeighborDetail {
957                address: address.clone(),
958                column: None,
959                row: None,
960                kind: TraceCellKind::External,
961                value: None,
962                formula: None,
963                fingerprint: None,
964                external: true,
965            });
966            continue;
967        };
968
969        let cell_ref_upper = cell_ref.to_ascii_uppercase();
970
971        if is_external {
972            let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
973            details.push(NeighborDetail {
974                address: address.clone(),
975                column: None,
976                row: None,
977                kind: TraceCellKind::External,
978                value: None,
979                formula: formula_info.map(|info| info.formula.clone()),
980                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
981                external: true,
982            });
983            continue;
984        }
985
986        let Some((col, row)) = parse_address(&cell_ref_upper) else {
987            details.push(NeighborDetail {
988                address: address.clone(),
989                column: None,
990                row: None,
991                kind: TraceCellKind::External,
992                value: None,
993                formula: None,
994                fingerprint: None,
995                external: true,
996            });
997            continue;
998        };
999
1000        let cell_opt = sheet.get_cell((&col, &row));
1001        let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
1002        if let Some(cell) = cell_opt {
1003            let value = cell_to_value(cell);
1004            let kind = if cell.is_formula() {
1005                TraceCellKind::Formula
1006            } else if value.is_some() {
1007                TraceCellKind::Literal
1008            } else {
1009                TraceCellKind::Blank
1010            };
1011            details.push(NeighborDetail {
1012                address: address.clone(),
1013                column: Some(col),
1014                row: Some(row),
1015                kind,
1016                value,
1017                formula: formula_info.map(|info| info.formula.clone()),
1018                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
1019                external: false,
1020            });
1021        } else {
1022            details.push(NeighborDetail {
1023                address: address.clone(),
1024                column: Some(col),
1025                row: Some(row),
1026                kind: TraceCellKind::Blank,
1027                value: None,
1028                formula: formula_info.map(|info| info.formula.clone()),
1029                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
1030                external: false,
1031            });
1032        }
1033    }
1034    details
1035}
1036
1037fn build_layer_summary(details: &[NeighborDetail]) -> TraceLayerSummary {
1038    let mut summary = TraceLayerSummary {
1039        total_nodes: details.len(),
1040        formula_nodes: 0,
1041        value_nodes: 0,
1042        blank_nodes: 0,
1043        external_nodes: 0,
1044        unique_formula_groups: 0,
1045    };
1046
1047    let mut fingerprints: HashSet<String> = HashSet::new();
1048
1049    for detail in details {
1050        match detail.kind {
1051            TraceCellKind::Formula => {
1052                summary.formula_nodes += 1;
1053                if let Some(fp) = &detail.fingerprint {
1054                    fingerprints.insert(fp.clone());
1055                }
1056            }
1057            TraceCellKind::Literal => summary.value_nodes += 1,
1058            TraceCellKind::Blank => summary.blank_nodes += 1,
1059            TraceCellKind::External => summary.external_nodes += 1,
1060        }
1061    }
1062
1063    summary.unique_formula_groups = fingerprints.len();
1064    summary
1065}
1066
1067fn build_formula_group_highlights(details: &[NeighborDetail]) -> Vec<TraceFormulaGroupHighlight> {
1068    let mut aggregates: HashMap<String, (String, usize, Vec<String>)> = HashMap::new();
1069    for detail in details {
1070        if let (Some(fingerprint), Some(formula)) = (&detail.fingerprint, &detail.formula) {
1071            let entry = aggregates
1072                .entry(fingerprint.clone())
1073                .or_insert_with(|| (formula.clone(), 0, Vec::new()));
1074            entry.1 += 1;
1075            if entry.2.len() < TRACE_GROUP_SAMPLE_LIMIT {
1076                entry.2.push(detail.address.clone());
1077            }
1078        }
1079    }
1080
1081    let mut highlights: Vec<TraceFormulaGroupHighlight> = aggregates
1082        .into_iter()
1083        .map(
1084            |(fingerprint, (formula, count, sample_addresses))| TraceFormulaGroupHighlight {
1085                fingerprint,
1086                formula,
1087                count,
1088                sample_addresses,
1089            },
1090        )
1091        .collect();
1092
1093    highlights.sort_by(|a, b| b.count.cmp(&a.count));
1094    highlights.truncate(TRACE_GROUP_HIGHLIGHT_LIMIT);
1095    highlights
1096}
1097
1098fn build_range_highlights(details: &[NeighborDetail]) -> Vec<TraceRangeHighlight> {
1099    let mut by_column: HashMap<u32, Vec<&NeighborDetail>> = HashMap::new();
1100    for detail in details {
1101        if let (Some(col), Some(_row)) = (detail.column, detail.row)
1102            && !detail.external {
1103                by_column.entry(col).or_default().push(detail);
1104            }
1105    }
1106
1107    for column_entries in by_column.values_mut() {
1108        column_entries.sort_by(|a, b| a.row.cmp(&b.row));
1109    }
1110
1111    let mut ranges = Vec::new();
1112    for entries in by_column.values() {
1113        let mut current: Vec<&NeighborDetail> = Vec::new();
1114        for detail in entries {
1115            if current.is_empty() {
1116                current.push(detail);
1117                continue;
1118            }
1119            let prev_row = current.last().and_then(|d| d.row).unwrap_or(0);
1120            if detail.row.unwrap_or(0) == prev_row + 1 {
1121                current.push(detail);
1122            } else {
1123                if current.len() >= TRACE_RANGE_THRESHOLD {
1124                    ranges.push(make_range_highlight(&current));
1125                }
1126                current.clear();
1127                current.push(detail);
1128            }
1129        }
1130        if current.len() >= TRACE_RANGE_THRESHOLD {
1131            ranges.push(make_range_highlight(&current));
1132        }
1133    }
1134
1135    ranges.sort_by(|a, b| b.count.cmp(&a.count));
1136    ranges.truncate(TRACE_RANGE_HIGHLIGHT_LIMIT);
1137    ranges
1138}
1139
1140fn make_range_highlight(details: &[&NeighborDetail]) -> TraceRangeHighlight {
1141    let mut literals = 0usize;
1142    let mut formulas = 0usize;
1143    let mut blanks = 0usize;
1144    let mut sample_values = Vec::new();
1145    let mut sample_formulas = Vec::new();
1146    let mut sample_addresses = Vec::new();
1147
1148    for detail in details {
1149        match detail.kind {
1150            TraceCellKind::Formula => {
1151                formulas += 1;
1152                if let Some(formula) = &detail.formula
1153                    && sample_formulas.len() < TRACE_RANGE_FORMULA_SAMPLES
1154                        && !sample_formulas.contains(formula)
1155                    {
1156                        sample_formulas.push(formula.clone());
1157                    }
1158            }
1159            TraceCellKind::Literal => {
1160                literals += 1;
1161                if let Some(value) = &detail.value
1162                    && sample_values.len() < TRACE_RANGE_VALUE_SAMPLES {
1163                        sample_values.push(value.clone());
1164                    }
1165            }
1166            TraceCellKind::Blank => blanks += 1,
1167            TraceCellKind::External => {}
1168        }
1169        if sample_addresses.len() < TRACE_RANGE_VALUE_SAMPLES {
1170            sample_addresses.push(detail.address.clone());
1171        }
1172    }
1173
1174    TraceRangeHighlight {
1175        start: details
1176            .first()
1177            .map(|d| d.address.clone())
1178            .unwrap_or_default(),
1179        end: details
1180            .last()
1181            .map(|d| d.address.clone())
1182            .unwrap_or_default(),
1183        count: details.len(),
1184        literals,
1185        formulas,
1186        blanks,
1187        sample_values,
1188        sample_formulas,
1189        sample_addresses,
1190    }
1191}
1192
1193fn build_notable_cells(
1194    details: &[NeighborDetail],
1195    ranges: &[TraceRangeHighlight],
1196    groups: &[TraceFormulaGroupHighlight],
1197) -> Vec<TraceCellHighlight> {
1198    let mut exclude: HashSet<String> = HashSet::new();
1199    for range in ranges {
1200        exclude.insert(range.start.clone());
1201        exclude.insert(range.end.clone());
1202        for addr in &range.sample_addresses {
1203            exclude.insert(addr.clone());
1204        }
1205    }
1206    for group in groups {
1207        for addr in &group.sample_addresses {
1208            exclude.insert(addr.clone());
1209        }
1210    }
1211
1212    let mut highlights = Vec::new();
1213    let mut kind_counts: HashMap<TraceCellKind, usize> = HashMap::new();
1214
1215    for detail in details {
1216        if highlights.len() >= TRACE_CELL_HIGHLIGHT_LIMIT {
1217            break;
1218        }
1219        if exclude.contains(&detail.address) {
1220            continue;
1221        }
1222        let counter = kind_counts.entry(detail.kind.clone()).or_insert(0);
1223        if *counter >= 2 && detail.kind != TraceCellKind::External {
1224            continue;
1225        }
1226        highlights.push(TraceCellHighlight {
1227            address: detail.address.clone(),
1228            kind: detail.kind.clone(),
1229            value: detail.value.clone(),
1230            formula: detail.formula.clone(),
1231        });
1232        *counter += 1;
1233    }
1234
1235    highlights
1236}
1237
1238fn build_edges_for_layer(
1239    raw_edges: &[TraceEdgeRaw],
1240    selected: &HashSet<String>,
1241    formula_lookup: &HashMap<String, TraceFormulaInfo>,
1242) -> Vec<FormulaTraceEdge> {
1243    let mut edges = Vec::new();
1244    for edge in raw_edges {
1245        if selected.contains(&edge.neighbor) {
1246            let formula = lookup_formula_info(formula_lookup, &edge.neighbor, &edge.neighbor)
1247                .map(|info| info.formula.clone());
1248            edges.push(FormulaTraceEdge {
1249                from: edge.from.clone(),
1250                to: edge.to.clone(),
1251                formula,
1252                note: None,
1253            });
1254        }
1255    }
1256    edges.sort_by(|a, b| compare_addresses(&a.to, &b.to));
1257    edges
1258}
1259
1260fn lookup_formula_info<'a>(
1261    lookup: &'a HashMap<String, TraceFormulaInfo>,
1262    cell_ref: &str,
1263    original: &str,
1264) -> Option<&'a TraceFormulaInfo> {
1265    if let Some(info) = lookup.get(cell_ref) {
1266        return Some(info);
1267    }
1268    if let (Some(_sheet), Some(cell)) = split_sheet_and_cell(original) {
1269        let upper = cell.to_ascii_uppercase();
1270        return lookup.get(&upper);
1271    }
1272    None
1273}
1274
1275fn compare_addresses(left: &str, right: &str) -> Ordering {
1276    let (sheet_left, cell_left) = split_sheet_and_cell(left);
1277    let (sheet_right, cell_right) = split_sheet_and_cell(right);
1278
1279    let sheet_left_key = sheet_left
1280        .as_ref()
1281        .map(|s| clean_sheet_name(s).to_ascii_uppercase())
1282        .unwrap_or_default();
1283    let sheet_right_key = sheet_right
1284        .as_ref()
1285        .map(|s| clean_sheet_name(s).to_ascii_uppercase())
1286        .unwrap_or_default();
1287
1288    match sheet_left_key.cmp(&sheet_right_key) {
1289        Ordering::Equal => {
1290            let left_core = cell_left.unwrap_or_else(|| left.to_string());
1291            let right_core = cell_right.unwrap_or_else(|| right.to_string());
1292            let left_coords = parse_address(&left_core.to_ascii_uppercase());
1293            let right_coords = parse_address(&right_core.to_ascii_uppercase());
1294            match (left_coords, right_coords) {
1295                (Some((lc, lr)), Some((rc, rr))) => lc
1296                    .cmp(&rc)
1297                    .then_with(|| lr.cmp(&rr))
1298                    .then_with(|| left_core.cmp(&right_core)),
1299                _ => left_core.cmp(&right_core),
1300            }
1301        }
1302        other => other,
1303    }
1304}
1305
1306fn split_sheet_and_cell(address: &str) -> (Option<String>, Option<String>) {
1307    if let Some(idx) = address.rfind('!') {
1308        let sheet = address[..idx].to_string();
1309        let cell = address[idx + 1..].to_string();
1310        (Some(sheet), Some(cell))
1311    } else {
1312        (None, Some(address.to_string()))
1313    }
1314}
1315
1316fn clean_sheet_name(sheet: &str) -> String {
1317    let trimmed = sheet.trim_matches(|c| c == '\'' || c == '"');
1318    let after_bracket = trimmed.rsplit(']').next().unwrap_or(trimmed);
1319    after_bracket
1320        .trim_matches(|c| c == '\'' || c == '"')
1321        .to_string()
1322}