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(¶ms.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(¶ms.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(¶ms.workbook_id).await?;
90 let overview = workbook.sheet_overview(¶ms.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(¶ms.workbook_id).await?;
131 let metrics = workbook.get_sheet_metrics(¶ms.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(¶ms.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(¶ms.workbook_id).await?;
183 let graph = workbook.formula_graph(¶ms.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) = ¶ms.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(¶ms.workbook_id).await?;
230 let graph = workbook.formula_graph(¶ms.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: ¶ms.direction,
242 origin: &origin,
243 sheet_name: ¶ms.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(¶ms.workbook_id).await?;
280 let mut items = workbook.named_items()?;
281
282 if let Some(sheet_filter) = ¶ms.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) = ¶ms.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(¶ms.workbook_id).await?;
459 let sheet_metrics = workbook.get_sheet_metrics(¶ms.sheet_name)?;
460 let sample_rows = params.sample_rows.unwrap_or_else(default_stats_sample);
461 let stats = workbook.with_sheet(¶ms.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(¶ms.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) = ¶ms.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(¶ms.workbook_id).await?;
563 let target_sheets: Vec<String> = if let Some(sheet) = ¶ms.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(¶ms.workbook_id).await?;
612 let entry = workbook.get_sheet_metrics(¶ms.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(¶ms.workbook_id).await?;
647 let mut summaries = workbook.list_summaries()?;
648
649 if let Some(filter) = ¶ms.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(¶ms.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(¤t));
1125 }
1126 current.clear();
1127 current.push(detail);
1128 }
1129 }
1130 if current.len() >= TRACE_RANGE_THRESHOLD {
1131 ranges.push(make_range_highlight(¤t));
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}