Skip to main content

spreadsheet_mcp/
workbook.rs

1use crate::analysis::{
2    classification,
3    formula::{FormulaAtlas, FormulaGraph},
4    style,
5};
6use crate::caps::BackendCaps;
7use crate::config::ServerConfig;
8use crate::model::{
9    NamedItemKind, NamedRangeDescriptor, SheetClassification, SheetOverviewResponse, SheetSummary,
10    WorkbookDescription, WorkbookId, WorkbookListResponse,
11};
12use crate::tools::filters::WorkbookFilter;
13use crate::utils::{
14    hash_bytes_sha256_hex, hash_file_sha256_hex, hash_path_identity, make_short_workbook_id,
15    path_to_forward_slashes, system_time_to_rfc3339,
16};
17use anyhow::{Context, Result, anyhow};
18use chrono::{DateTime, Utc};
19use parking_lot::RwLock;
20use std::cmp::Ordering;
21use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::io::Cursor;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::time::Instant;
27use umya_spreadsheet::reader::xlsx;
28use umya_spreadsheet::{DefinedName, Spreadsheet, Worksheet};
29
30const KV_MAX_WIDTH_FOR_DENSITY_CHECK: u32 = 6;
31const KV_SAMPLE_ROWS: u32 = 20;
32const KV_DENSITY_THRESHOLD: f32 = 0.4;
33const KV_CHECK_ROWS: u32 = 15;
34const KV_MAX_LABEL_LEN: usize = 25;
35const KV_MIN_TEXT_VALUE_LEN: usize = 2;
36const KV_MIN_PAIRS: u32 = 3;
37const KV_MIN_PAIR_RATIO: f32 = 0.3;
38
39const HEADER_MAX_SCAN_ROWS: u32 = 2;
40const HEADER_LONG_STRING_PENALTY_THRESHOLD: usize = 40;
41const HEADER_LONG_STRING_PENALTY: f32 = 1.5;
42const HEADER_PROPER_NOUN_MIN_LEN: usize = 5;
43const HEADER_PROPER_NOUN_PENALTY: f32 = 1.0;
44const HEADER_DIGIT_STRING_MIN_LEN: usize = 3;
45const HEADER_DIGIT_STRING_PENALTY: f32 = 0.5;
46const HEADER_DATE_PENALTY: f32 = 1.0;
47const HEADER_YEAR_LIKE_BONUS: f32 = 0.5;
48const HEADER_YEAR_MIN: f64 = 1900.0;
49const HEADER_YEAR_MAX: f64 = 2100.0;
50const HEADER_UNIQUE_BONUS: f32 = 0.2;
51const HEADER_NUMBER_PENALTY: f32 = 0.3;
52const HEADER_SINGLE_COL_MIN_SCORE: f32 = 1.5;
53const HEADER_SCORE_TIE_THRESHOLD: f32 = 0.3;
54const HEADER_SECOND_ROW_MIN_SCORE_RATIO: f32 = 0.6;
55const HEADER_MAX_COLUMNS: u32 = 200;
56
57const DETECT_MAX_ROWS: u32 = 10_000;
58const DETECT_MAX_COLS: u32 = 500;
59const DETECT_MAX_AREA: u64 = 5_000_000;
60const DETECT_MAX_CELLS: usize = 200_000;
61const DETECT_MAX_LEAVES: usize = 200;
62const DETECT_MAX_DEPTH: u32 = 12;
63const DETECT_MAX_MS: u64 = 200;
64const DETECT_OUTLIER_FRACTION: f32 = 0.01;
65const DETECT_OUTLIER_MIN_CELLS: usize = 50;
66
67pub struct WorkbookContext {
68    pub id: WorkbookId,
69    pub short_id: String,
70    pub revision_id: String,
71    pub slug: String,
72    pub path: PathBuf,
73    pub caps: BackendCaps,
74    pub bytes: u64,
75    pub last_modified: Option<DateTime<Utc>>,
76    spreadsheet: Arc<RwLock<Spreadsheet>>,
77    sheet_cache: RwLock<HashMap<String, Arc<SheetCacheEntry>>>,
78    formula_atlas: Arc<FormulaAtlas>,
79}
80
81pub struct SheetCacheEntry {
82    pub metrics: SheetMetrics,
83    pub style_tags: Vec<String>,
84    pub named_ranges: Vec<NamedRangeDescriptor>,
85    detected_regions: RwLock<Option<Vec<crate::model::DetectedRegion>>>,
86    region_notes: RwLock<Vec<String>>,
87}
88
89#[derive(Debug, Clone)]
90pub struct SheetMetrics {
91    pub row_count: u32,
92    pub column_count: u32,
93    pub non_empty_cells: u32,
94    pub formula_cells: u32,
95    pub cached_values: u32,
96    pub comments: u32,
97    pub style_map: HashMap<String, StyleUsage>,
98    pub classification: SheetClassification,
99}
100
101#[derive(Debug, Clone)]
102pub struct StyleUsage {
103    pub occurrences: u32,
104    pub tags: Vec<String>,
105    pub example_cells: Vec<String>,
106}
107
108impl SheetCacheEntry {
109    pub fn detected_regions(&self) -> Vec<crate::model::DetectedRegion> {
110        self.detected_regions
111            .read()
112            .as_ref()
113            .cloned()
114            .unwrap_or_default()
115    }
116
117    pub fn region_notes(&self) -> Vec<String> {
118        self.region_notes.read().clone()
119    }
120
121    pub fn has_detected_regions(&self) -> bool {
122        self.detected_regions.read().is_some()
123    }
124
125    pub fn set_detected_regions(&self, regions: Vec<crate::model::DetectedRegion>) {
126        let mut guard = self.detected_regions.write();
127        if guard.is_none() {
128            *guard = Some(regions);
129        }
130    }
131
132    pub fn set_region_notes(&self, notes: Vec<String>) {
133        if notes.is_empty() {
134            return;
135        }
136        let mut guard = self.region_notes.write();
137        if guard.is_empty() {
138            *guard = notes;
139        }
140    }
141}
142
143impl WorkbookContext {
144    pub fn load(_config: &Arc<ServerConfig>, path: &Path) -> Result<Self> {
145        fs::metadata(path).with_context(|| format!("unable to read metadata for {:?}", path))?;
146        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
147        let slug = path
148            .file_stem()
149            .map(|s| s.to_string_lossy().to_string())
150            .unwrap_or_else(|| "workbook".to_string());
151        let id = WorkbookId(hash_path_identity(&canonical));
152        let short_id = make_short_workbook_id(&slug, id.as_str());
153        let revision_id = hash_file_sha256_hex(path)
154            .with_context(|| format!("unable to hash workbook {:?}", path))?;
155
156        Self::load_from_path(_config, path, id, short_id, Some(revision_id))
157    }
158
159    pub fn load_from_path(
160        _config: &Arc<ServerConfig>,
161        path: &Path,
162        stable_id: WorkbookId,
163        short_id: String,
164        revision_id: Option<String>,
165    ) -> Result<Self> {
166        let metadata = fs::metadata(path)
167            .with_context(|| format!("unable to read metadata for {:?}", path))?;
168        let slug = path
169            .file_stem()
170            .map(|s| s.to_string_lossy().to_string())
171            .unwrap_or_else(|| "workbook".to_string());
172        let bytes = metadata.len();
173        let last_modified = metadata.modified().ok().and_then(system_time_to_rfc3339);
174        let revision_id = match revision_id {
175            Some(id) => id,
176            None => hash_file_sha256_hex(path)
177                .with_context(|| format!("unable to hash workbook {:?}", path))?,
178        };
179        let spreadsheet =
180            xlsx::read(path).with_context(|| format!("failed to parse workbook {:?}", path))?;
181
182        Ok(Self {
183            id: stable_id,
184            short_id,
185            revision_id,
186            slug,
187            path: path.to_path_buf(),
188            caps: BackendCaps::xlsx(),
189            bytes,
190            last_modified,
191            spreadsheet: Arc::new(RwLock::new(spreadsheet)),
192            sheet_cache: RwLock::new(HashMap::new()),
193            formula_atlas: Arc::new(FormulaAtlas::default()),
194        })
195    }
196
197    pub fn load_from_bytes(
198        _config: &Arc<ServerConfig>,
199        display_name: &str,
200        bytes: &[u8],
201        stable_id: WorkbookId,
202        short_id: String,
203        revision_id: Option<String>,
204    ) -> Result<Self> {
205        let slug = Path::new(display_name)
206            .file_stem()
207            .map(|s| s.to_string_lossy().to_string())
208            .unwrap_or_else(|| "workbook".to_string());
209        let cursor = Cursor::new(bytes);
210        let spreadsheet = xlsx::read_reader(cursor, true)
211            .with_context(|| format!("failed to parse workbook bytes for {display_name}"))?;
212        let revision_id = revision_id.unwrap_or_else(|| hash_bytes_sha256_hex(bytes));
213
214        Ok(Self {
215            id: stable_id,
216            short_id,
217            revision_id,
218            slug,
219            path: PathBuf::from(format!("virtual/{display_name}")),
220            caps: BackendCaps::xlsx(),
221            bytes: bytes.len() as u64,
222            last_modified: None,
223            spreadsheet: Arc::new(RwLock::new(spreadsheet)),
224            sheet_cache: RwLock::new(HashMap::new()),
225            formula_atlas: Arc::new(FormulaAtlas::default()),
226        })
227    }
228
229    pub fn sheet_names(&self) -> Vec<String> {
230        let book = self.spreadsheet.read();
231        book.get_sheet_collection()
232            .iter()
233            .map(|sheet| sheet.get_name().to_string())
234            .collect()
235    }
236
237    pub fn describe(&self) -> WorkbookDescription {
238        let book = self.spreadsheet.read();
239        let defined_names_count = book.get_defined_names().len();
240        let table_count: usize = book
241            .get_sheet_collection()
242            .iter()
243            .map(|sheet| sheet.get_tables().len())
244            .sum();
245        let macros_present = false;
246
247        WorkbookDescription {
248            workbook_id: self.id.clone(),
249            short_id: self.short_id.clone(),
250            slug: self.slug.clone(),
251            path: path_to_forward_slashes(&self.path),
252            client_path: None,
253            bytes: self.bytes,
254            sheet_count: book.get_sheet_collection().len(),
255            defined_names: defined_names_count,
256            tables: table_count,
257            macros_present,
258            last_modified: self
259                .last_modified
260                .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
261            revision_id: Some(self.revision_id.clone()),
262            caps: self.caps.clone(),
263        }
264    }
265
266    pub fn get_sheet_metrics_fast(&self, sheet_name: &str) -> Result<Arc<SheetCacheEntry>> {
267        if let Some(entry) = self.sheet_cache.read().get(sheet_name) {
268            return Ok(entry.clone());
269        }
270
271        let mut writer = self.sheet_cache.write();
272        if let Some(entry) = writer.get(sheet_name) {
273            return Ok(entry.clone());
274        }
275
276        let book = self.spreadsheet.read();
277        let sheet = book
278            .get_sheet_by_name(sheet_name)
279            .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
280        let (metrics, style_tags) = compute_sheet_metrics(sheet);
281        let named_ranges = gather_named_ranges(sheet, book.get_defined_names());
282
283        let entry = Arc::new(SheetCacheEntry {
284            metrics,
285            style_tags,
286            named_ranges,
287            detected_regions: RwLock::new(None),
288            region_notes: RwLock::new(Vec::new()),
289        });
290
291        writer.insert(sheet_name.to_string(), entry.clone());
292        Ok(entry)
293    }
294
295    pub fn get_sheet_metrics(&self, sheet_name: &str) -> Result<Arc<SheetCacheEntry>> {
296        let entry = self.get_sheet_metrics_fast(sheet_name)?;
297        if entry.has_detected_regions() {
298            return Ok(entry);
299        }
300
301        let book = self.spreadsheet.read();
302        let sheet = book
303            .get_sheet_by_name(sheet_name)
304            .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
305        let detected = detect_regions(sheet, &entry.metrics);
306        entry.set_detected_regions(detected.regions);
307        entry.set_region_notes(detected.notes);
308        Ok(entry)
309    }
310
311    pub fn list_summaries(&self, include_bounds: bool) -> Result<Vec<SheetSummary>> {
312        let book = self.spreadsheet.read();
313        let mut summaries = Vec::new();
314        for sheet in book.get_sheet_collection() {
315            let name = sheet.get_name().to_string();
316            let entry = self.get_sheet_metrics_fast(&name)?;
317            summaries.push(SheetSummary {
318                name: name.clone(),
319                visible: sheet.get_sheet_state() != "hidden",
320                row_count: include_bounds.then_some(entry.metrics.row_count),
321                column_count: include_bounds.then_some(entry.metrics.column_count),
322                non_empty_cells: include_bounds.then_some(entry.metrics.non_empty_cells),
323                formula_cells: include_bounds.then_some(entry.metrics.formula_cells),
324                cached_values: include_bounds.then_some(entry.metrics.cached_values),
325                classification: entry.metrics.classification.clone(),
326                style_tags: if include_bounds {
327                    entry.style_tags.clone()
328                } else {
329                    Vec::new()
330                },
331            });
332        }
333        Ok(summaries)
334    }
335
336    pub fn with_sheet<T, F>(&self, sheet_name: &str, func: F) -> Result<T>
337    where
338        F: FnOnce(&Worksheet) -> T,
339    {
340        let book = self.spreadsheet.read();
341        let sheet = book
342            .get_sheet_by_name(sheet_name)
343            .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
344        Ok(func(sheet))
345    }
346
347    pub fn with_spreadsheet<T, F>(&self, func: F) -> Result<T>
348    where
349        F: FnOnce(&Spreadsheet) -> T,
350    {
351        let book = self.spreadsheet.read();
352        Ok(func(&book))
353    }
354
355    pub fn formula_graph(&self, sheet_name: &str) -> Result<FormulaGraph> {
356        self.with_sheet(sheet_name, |sheet| {
357            FormulaGraph::build(sheet, &self.formula_atlas)
358        })?
359    }
360
361    pub fn named_items(&self) -> Result<Vec<NamedRangeDescriptor>> {
362        let book = self.spreadsheet.read();
363        let sheet_names: Vec<String> = book
364            .get_sheet_collection()
365            .iter()
366            .map(|sheet| sheet.get_name().to_string())
367            .collect();
368        let mut items = Vec::new();
369        for defined in book.get_defined_names() {
370            let refers_to = defined.get_address();
371            let scope = if defined.has_local_sheet_id() {
372                let idx = *defined.get_local_sheet_id() as usize;
373                sheet_names.get(idx).cloned()
374            } else {
375                None
376            };
377            let kind = if refers_to.starts_with('=') {
378                NamedItemKind::Formula
379            } else {
380                NamedItemKind::NamedRange
381            };
382
383            items.push(NamedRangeDescriptor {
384                name: defined.get_name().to_string(),
385                scope: scope.clone(),
386                refers_to: refers_to.clone(),
387                kind,
388                sheet_name: scope,
389                comment: None,
390            });
391        }
392
393        for sheet in book.get_sheet_collection() {
394            for table in sheet.get_tables() {
395                let start = table.get_area().0.get_coordinate();
396                let end = table.get_area().1.get_coordinate();
397                items.push(NamedRangeDescriptor {
398                    name: table.get_name().to_string(),
399                    scope: Some(sheet.get_name().to_string()),
400                    refers_to: format!("{}:{}", start, end),
401                    kind: NamedItemKind::Table,
402                    sheet_name: Some(sheet.get_name().to_string()),
403                    comment: None,
404                });
405            }
406        }
407
408        Ok(items)
409    }
410
411    pub fn sheet_overview(&self, sheet_name: &str) -> Result<SheetOverviewResponse> {
412        let entry = self.get_sheet_metrics(sheet_name)?;
413        let narrative = classification::narrative(&entry.metrics);
414        let regions = classification::regions(&entry.metrics);
415        let key_ranges = classification::key_ranges(&entry.metrics);
416        let detected_regions = entry.detected_regions();
417
418        Ok(SheetOverviewResponse {
419            workbook_id: self.id.clone(),
420            workbook_short_id: self.short_id.clone(),
421            sheet_name: sheet_name.to_string(),
422            narrative,
423            regions,
424            detected_regions: detected_regions.clone(),
425            detected_region_count: detected_regions.len() as u32,
426            detected_regions_truncated: false,
427            key_ranges,
428            formula_ratio: if entry.metrics.non_empty_cells == 0 {
429                0.0
430            } else {
431                entry.metrics.formula_cells as f32 / entry.metrics.non_empty_cells as f32
432            },
433            notable_features: entry.style_tags.clone(),
434            notes: entry.region_notes(),
435        })
436    }
437
438    pub fn detected_region(
439        &self,
440        sheet_name: &str,
441        id: u32,
442    ) -> Result<crate::model::DetectedRegion> {
443        let entry = self.get_sheet_metrics(sheet_name)?;
444        entry
445            .detected_regions()
446            .iter()
447            .find(|r| r.id == id)
448            .cloned()
449            .ok_or_else(|| anyhow!("region {} not found on sheet {}", id, sheet_name))
450    }
451}
452
453fn contains_date_time_token(format_code: &str) -> bool {
454    let mut in_quote = false;
455    let mut in_bracket = false;
456    let chars: Vec<char> = format_code.chars().collect();
457
458    for (i, &ch) in chars.iter().enumerate() {
459        match ch {
460            '"' => in_quote = !in_quote,
461            '[' if !in_quote => in_bracket = true,
462            ']' if !in_quote => in_bracket = false,
463            'y' | 'd' | 'h' | 's' | 'm' if !in_quote && !in_bracket => {
464                if ch == 'm' {
465                    let prev = if i > 0 { chars.get(i - 1) } else { None };
466                    let next = chars.get(i + 1);
467                    let after_time_sep = prev == Some(&':') || prev == Some(&'h');
468                    let before_time_sep = next == Some(&':') || next == Some(&'s');
469                    if after_time_sep || before_time_sep {
470                        return true;
471                    }
472                    if prev == Some(&'m') || next == Some(&'m') {
473                        return true;
474                    }
475                    if matches!(prev, Some(&'/') | Some(&'-') | Some(&'.'))
476                        || matches!(next, Some(&'/') | Some(&'-') | Some(&'.'))
477                    {
478                        return true;
479                    }
480                } else {
481                    return true;
482                }
483            }
484            _ => {}
485        }
486    }
487    false
488}
489
490const DATE_FORMAT_IDS: &[u32] = &[
491    14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 45, 46, 47, 50, 51,
492    52, 53, 54, 55, 56, 57, 58,
493];
494
495const EXCEL_LEAP_YEAR_BUG_SERIAL: i64 = 60;
496
497fn is_date_formatted(cell: &umya_spreadsheet::Cell) -> bool {
498    let Some(nf) = cell.get_style().get_number_format() else {
499        return false;
500    };
501
502    let format_id = nf.get_number_format_id();
503    if DATE_FORMAT_IDS.contains(format_id) {
504        return true;
505    }
506
507    let code = nf.get_format_code();
508    if code == "General" || code == "@" || code == "0" || code == "0.00" {
509        return false;
510    }
511
512    contains_date_time_token(code)
513}
514
515pub fn excel_serial_to_iso(serial: f64, use_1904_system: bool) -> String {
516    excel_serial_to_iso_with_leap_bug(serial, use_1904_system, true)
517}
518
519pub fn excel_serial_to_iso_with_leap_bug(
520    serial: f64,
521    use_1904_system: bool,
522    compensate_leap_bug: bool,
523) -> String {
524    use chrono::NaiveDate;
525
526    let days = serial.trunc() as i64;
527
528    if use_1904_system {
529        let epoch_1904 = NaiveDate::from_ymd_opt(1904, 1, 1).unwrap();
530        return epoch_1904
531            .checked_add_signed(chrono::Duration::days(days))
532            .map(|d| d.format("%Y-%m-%d").to_string())
533            .unwrap_or_else(|| serial.to_string());
534    }
535
536    let epoch = if compensate_leap_bug && days >= EXCEL_LEAP_YEAR_BUG_SERIAL {
537        NaiveDate::from_ymd_opt(1899, 12, 30).unwrap()
538    } else {
539        NaiveDate::from_ymd_opt(1899, 12, 31).unwrap()
540    };
541
542    epoch
543        .checked_add_signed(chrono::Duration::days(days))
544        .map(|d| d.format("%Y-%m-%d").to_string())
545        .unwrap_or_else(|| serial.to_string())
546}
547
548pub fn cell_to_value(cell: &umya_spreadsheet::Cell) -> Option<crate::model::CellValue> {
549    cell_to_value_with_date_system(cell, false)
550}
551
552pub fn cell_to_value_with_date_system(
553    cell: &umya_spreadsheet::Cell,
554    use_1904_system: bool,
555) -> Option<crate::model::CellValue> {
556    let raw = cell.get_value();
557    if raw.is_empty() {
558        return None;
559    }
560    if let Ok(number) = raw.parse::<f64>() {
561        if is_date_formatted(cell) {
562            return Some(crate::model::CellValue::Date(excel_serial_to_iso(
563                number,
564                use_1904_system,
565            )));
566        }
567        return Some(crate::model::CellValue::Number(number));
568    }
569
570    let lower = raw.to_ascii_lowercase();
571    if lower == "true" {
572        return Some(crate::model::CellValue::Bool(true));
573    }
574    if lower == "false" {
575        return Some(crate::model::CellValue::Bool(false));
576    }
577
578    Some(crate::model::CellValue::Text(raw.to_string()))
579}
580
581pub fn compute_sheet_metrics(sheet: &Worksheet) -> (SheetMetrics, Vec<String>) {
582    use std::collections::HashMap as StdHashMap;
583    let mut non_empty = 0u32;
584    let mut formulas = 0u32;
585    let mut cached = 0u32;
586    let comments = sheet.get_comments().len() as u32;
587    let mut style_usage: StdHashMap<String, StyleUsage> = StdHashMap::new();
588
589    for cell in sheet.get_cell_collection() {
590        let value = cell.get_value();
591        if !value.is_empty() {
592            non_empty += 1;
593        }
594        if cell.is_formula() {
595            formulas += 1;
596            if !cell.get_value().is_empty() {
597                cached += 1;
598            }
599        }
600
601        if let Some((style_key, usage)) = style::tag_cell(cell) {
602            let entry = style_usage.entry(style_key).or_insert_with(|| StyleUsage {
603                occurrences: 0,
604                tags: usage.tags.clone(),
605                example_cells: Vec::new(),
606            });
607            entry.occurrences += 1;
608            if entry.example_cells.len() < 5 {
609                entry.example_cells.push(usage.example_cell.clone());
610            }
611        }
612    }
613
614    let (max_col, max_row) = sheet.get_highest_column_and_row();
615
616    let classification = classification::classify(
617        non_empty,
618        formulas,
619        max_row,
620        max_col,
621        comments,
622        &style_usage,
623    );
624
625    let style_tags: Vec<String> = style_usage
626        .values()
627        .flat_map(|usage| usage.tags.clone())
628        .collect();
629
630    let metrics = SheetMetrics {
631        row_count: max_row,
632        column_count: max_col,
633        non_empty_cells: non_empty,
634        formula_cells: formulas,
635        cached_values: cached,
636        comments,
637        style_map: style_usage,
638        classification,
639    };
640    (metrics, style_tags)
641}
642
643#[derive(Debug, Clone, Copy)]
644struct Rect {
645    start_row: u32,
646    end_row: u32,
647    start_col: u32,
648    end_col: u32,
649}
650
651#[derive(Debug, Clone)]
652struct CellInfo {
653    value: Option<crate::model::CellValue>,
654    is_formula: bool,
655}
656
657#[derive(Debug)]
658struct Occupancy {
659    cells: HashMap<(u32, u32), CellInfo>,
660    rows: HashMap<u32, Vec<u32>>,
661    cols: HashMap<u32, Vec<u32>>,
662    min_row: u32,
663    max_row: u32,
664    min_col: u32,
665    max_col: u32,
666}
667
668impl Occupancy {
669    fn bounds_rect(&self) -> Option<Rect> {
670        if self.cells.is_empty() {
671            None
672        } else {
673            Some(Rect {
674                start_row: self.min_row,
675                end_row: self.max_row,
676                start_col: self.min_col,
677                end_col: self.max_col,
678            })
679        }
680    }
681
682    fn dense_bounds(&self) -> Option<Rect> {
683        let bounds = self.bounds_rect()?;
684        let total_cells = self.cells.len();
685        if total_cells < DETECT_OUTLIER_MIN_CELLS {
686            return Some(bounds);
687        }
688        let trim_cells = ((total_cells as f32) * DETECT_OUTLIER_FRACTION).round() as usize;
689        if trim_cells == 0 || trim_cells * 2 >= total_cells {
690            return Some(bounds);
691        }
692
693        let mut row_counts: Vec<(u32, usize)> = self
694            .rows
695            .iter()
696            .map(|(row, cols)| (*row, cols.len()))
697            .collect();
698        row_counts.sort_by_key(|(row, _)| *row);
699
700        let mut col_counts: Vec<(u32, usize)> = self
701            .cols
702            .iter()
703            .map(|(col, rows)| (*col, rows.len()))
704            .collect();
705        col_counts.sort_by_key(|(col, _)| *col);
706
707        let (start_row, end_row) =
708            trim_bounds_by_cells(&row_counts, trim_cells, bounds.start_row, bounds.end_row);
709        let (start_col, end_col) =
710            trim_bounds_by_cells(&col_counts, trim_cells, bounds.start_col, bounds.end_col);
711
712        if start_row > end_row || start_col > end_col {
713            return Some(bounds);
714        }
715
716        Some(Rect {
717            start_row,
718            end_row,
719            start_col,
720            end_col,
721        })
722    }
723
724    fn row_col_counts(&self, rect: &Rect) -> (Vec<u32>, Vec<u32>) {
725        let height = (rect.end_row - rect.start_row + 1) as usize;
726        let width = (rect.end_col - rect.start_col + 1) as usize;
727        let mut row_counts = vec![0u32; height];
728        let mut col_counts = vec![0u32; width];
729
730        for (row, cols) in &self.rows {
731            if *row < rect.start_row || *row > rect.end_row {
732                continue;
733            }
734            let count = count_in_sorted_range(cols, rect.start_col, rect.end_col);
735            row_counts[(row - rect.start_row) as usize] = count;
736        }
737        for (col, rows) in &self.cols {
738            if *col < rect.start_col || *col > rect.end_col {
739                continue;
740            }
741            let count = count_in_sorted_range(rows, rect.start_row, rect.end_row);
742            col_counts[(col - rect.start_col) as usize] = count;
743        }
744        (row_counts, col_counts)
745    }
746
747    fn stats_in_rect(&self, rect: &Rect) -> RegionStats {
748        let mut stats = RegionStats::default();
749        for (row, cols) in &self.rows {
750            if *row < rect.start_row || *row > rect.end_row {
751                continue;
752            }
753            let start_idx = lower_bound(cols, rect.start_col);
754            let end_idx = upper_bound(cols, rect.end_col);
755            for col in &cols[start_idx..end_idx] {
756                if let Some(info) = self.cells.get(&(*row, *col)) {
757                    stats.non_empty += 1;
758                    if info.is_formula {
759                        stats.formulas += 1;
760                    }
761                    if let Some(val) = &info.value {
762                        match val {
763                            crate::model::CellValue::Text(_) => stats.text += 1,
764                            crate::model::CellValue::Number(_) => stats.numbers += 1,
765                            crate::model::CellValue::Bool(_) => stats.bools += 1,
766                            crate::model::CellValue::Date(_) => stats.dates += 1,
767                            crate::model::CellValue::Error(_) => stats.errors += 1,
768                        }
769                    }
770                }
771            }
772        }
773        stats
774    }
775
776    fn value_at(&self, row: u32, col: u32) -> Option<&crate::model::CellValue> {
777        self.cells.get(&(row, col)).and_then(|c| c.value.as_ref())
778    }
779}
780
781fn lower_bound(values: &[u32], target: u32) -> usize {
782    let mut left = 0;
783    let mut right = values.len();
784    while left < right {
785        let mid = (left + right) / 2;
786        if values[mid] < target {
787            left = mid + 1;
788        } else {
789            right = mid;
790        }
791    }
792    left
793}
794
795fn upper_bound(values: &[u32], target: u32) -> usize {
796    let mut left = 0;
797    let mut right = values.len();
798    while left < right {
799        let mid = (left + right) / 2;
800        if values[mid] <= target {
801            left = mid + 1;
802        } else {
803            right = mid;
804        }
805    }
806    left
807}
808
809fn count_in_sorted_range(values: &[u32], start: u32, end: u32) -> u32 {
810    if values.is_empty() {
811        return 0;
812    }
813    let start_idx = lower_bound(values, start);
814    let end_idx = upper_bound(values, end);
815    end_idx.saturating_sub(start_idx) as u32
816}
817
818fn trim_bounds_by_cells(
819    entries: &[(u32, usize)],
820    trim_cells: usize,
821    default_start: u32,
822    default_end: u32,
823) -> (u32, u32) {
824    if entries.is_empty() {
825        return (default_start, default_end);
826    }
827
828    let mut remaining = trim_cells;
829    let mut start_idx = 0usize;
830    while start_idx < entries.len() {
831        let count = entries[start_idx].1;
832        if remaining < count {
833            break;
834        }
835        remaining -= count;
836        start_idx += 1;
837    }
838
839    let mut remaining = trim_cells;
840    let mut end_idx = entries.len();
841    while end_idx > 0 {
842        let count = entries[end_idx - 1].1;
843        if remaining < count {
844            break;
845        }
846        remaining -= count;
847        end_idx -= 1;
848    }
849
850    let start = entries
851        .get(start_idx)
852        .map(|(idx, _)| *idx)
853        .unwrap_or(default_start);
854    let end = if end_idx == 0 {
855        default_end
856    } else {
857        entries
858            .get(end_idx - 1)
859            .map(|(idx, _)| *idx)
860            .unwrap_or(default_end)
861    };
862    (start, end)
863}
864
865#[derive(Debug, Default, Clone)]
866struct RegionStats {
867    non_empty: u32,
868    formulas: u32,
869    text: u32,
870    numbers: u32,
871    bools: u32,
872    dates: u32,
873    errors: u32,
874}
875
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877enum Gutter {
878    Row { start: u32, end: u32 },
879    Col { start: u32, end: u32 },
880}
881
882#[derive(Debug, Default)]
883struct DetectRegionsResult {
884    regions: Vec<crate::model::DetectedRegion>,
885    notes: Vec<String>,
886}
887
888#[derive(Debug)]
889struct DetectLimits {
890    start: Instant,
891    max_ms: u64,
892    max_leaves: usize,
893    max_depth: u32,
894    leaves: usize,
895    exceeded_time: bool,
896    exceeded_leaves: bool,
897}
898
899impl DetectLimits {
900    fn new() -> Self {
901        Self {
902            start: Instant::now(),
903            max_ms: DETECT_MAX_MS,
904            max_leaves: DETECT_MAX_LEAVES,
905            max_depth: DETECT_MAX_DEPTH,
906            leaves: 0,
907            exceeded_time: false,
908            exceeded_leaves: false,
909        }
910    }
911
912    fn should_stop(&mut self) -> bool {
913        if !self.exceeded_time && self.start.elapsed().as_millis() as u64 >= self.max_ms {
914            self.exceeded_time = true;
915        }
916        self.exceeded_time || self.exceeded_leaves
917    }
918
919    fn note_leaf(&mut self) {
920        self.leaves += 1;
921        if self.leaves >= self.max_leaves {
922            self.exceeded_leaves = true;
923        }
924    }
925}
926
927fn detect_regions(sheet: &Worksheet, metrics: &SheetMetrics) -> DetectRegionsResult {
928    if metrics.row_count == 0 || metrics.column_count == 0 {
929        return DetectRegionsResult::default();
930    }
931
932    let occupancy = build_occupancy(sheet);
933    if occupancy.cells.is_empty() {
934        return DetectRegionsResult::default();
935    }
936
937    let area = (metrics.row_count as u64) * (metrics.column_count as u64);
938    let exceeds_caps = metrics.row_count > DETECT_MAX_ROWS
939        || metrics.column_count > DETECT_MAX_COLS
940        || area > DETECT_MAX_AREA
941        || occupancy.cells.len() > DETECT_MAX_CELLS;
942
943    if exceeds_caps {
944        let mut result = DetectRegionsResult::default();
945        if let Some(bounds) = occupancy.dense_bounds() {
946            result.regions.push(build_fallback_region(&bounds, metrics));
947        }
948        result.notes.push(format!(
949            "Region detection capped: rows {}, cols {}, occupied {}.",
950            metrics.row_count,
951            metrics.column_count,
952            occupancy.cells.len()
953        ));
954        return result;
955    }
956
957    let root = occupancy.bounds_rect().unwrap_or(Rect {
958        start_row: 1,
959        end_row: metrics.row_count.max(1),
960        start_col: 1,
961        end_col: metrics.column_count.max(1),
962    });
963
964    let mut leaves = Vec::new();
965    let mut limits = DetectLimits::new();
966    split_rect(&occupancy, &root, 0, &mut limits, &mut leaves);
967
968    let mut regions = Vec::new();
969    for (idx, rect) in leaves.into_iter().enumerate() {
970        if limits.should_stop() {
971            break;
972        }
973        if let Some(trimmed) = trim_rect(&occupancy, rect, &mut limits) {
974            let region = build_region(&occupancy, &trimmed, metrics, idx as u32);
975            regions.push(region);
976        }
977    }
978
979    let mut notes = Vec::new();
980    if limits.exceeded_time || limits.exceeded_leaves {
981        notes.push("Region detection truncated due to time/complexity caps.".to_string());
982    }
983    if regions.is_empty()
984        && let Some(bounds) = occupancy.dense_bounds()
985    {
986        regions.push(build_fallback_region(&bounds, metrics));
987        notes.push("Region detection returned no regions; fallback bounds used.".to_string());
988    }
989
990    DetectRegionsResult { regions, notes }
991}
992
993fn build_fallback_region(rect: &Rect, metrics: &SheetMetrics) -> crate::model::DetectedRegion {
994    let kind = match metrics.classification {
995        SheetClassification::Calculator => crate::model::RegionKind::Calculator,
996        SheetClassification::Metadata => crate::model::RegionKind::Metadata,
997        _ => crate::model::RegionKind::Data,
998    };
999    let end_col = crate::utils::column_number_to_name(rect.end_col.max(1));
1000    let end_cell = format!("{}{}", end_col, rect.end_row.max(1));
1001    let header_count = rect.end_col - rect.start_col + 1;
1002    crate::model::DetectedRegion {
1003        id: 0,
1004        bounds: format!(
1005            "{}{}:{}",
1006            crate::utils::column_number_to_name(rect.start_col),
1007            rect.start_row,
1008            end_cell
1009        ),
1010        header_row: None,
1011        headers: Vec::new(),
1012        header_count,
1013        headers_truncated: header_count > 0,
1014        row_count: rect.end_row - rect.start_row + 1,
1015        classification: kind.clone(),
1016        region_kind: Some(kind),
1017        confidence: 0.2,
1018    }
1019}
1020
1021fn build_occupancy(sheet: &Worksheet) -> Occupancy {
1022    let mut cells = HashMap::new();
1023    let mut rows: HashMap<u32, Vec<u32>> = HashMap::new();
1024    let mut cols: HashMap<u32, Vec<u32>> = HashMap::new();
1025    let mut min_row = u32::MAX;
1026    let mut max_row = 0u32;
1027    let mut min_col = u32::MAX;
1028    let mut max_col = 0u32;
1029
1030    for cell in sheet.get_cell_collection() {
1031        let coord = cell.get_coordinate();
1032        let row = *coord.get_row_num();
1033        let col = *coord.get_col_num();
1034        let value = cell_to_value(cell);
1035        let is_formula = cell.is_formula();
1036        cells.insert((row, col), CellInfo { value, is_formula });
1037        rows.entry(row).or_default().push(col);
1038        cols.entry(col).or_default().push(row);
1039        min_row = min_row.min(row);
1040        max_row = max_row.max(row);
1041        min_col = min_col.min(col);
1042        max_col = max_col.max(col);
1043    }
1044
1045    for cols in rows.values_mut() {
1046        cols.sort_unstable();
1047    }
1048    for rows in cols.values_mut() {
1049        rows.sort_unstable();
1050    }
1051
1052    if cells.is_empty() {
1053        min_row = 0;
1054        min_col = 0;
1055    }
1056
1057    Occupancy {
1058        cells,
1059        rows,
1060        cols,
1061        min_row,
1062        max_row,
1063        min_col,
1064        max_col,
1065    }
1066}
1067
1068fn split_rect(
1069    occupancy: &Occupancy,
1070    rect: &Rect,
1071    depth: u32,
1072    limits: &mut DetectLimits,
1073    leaves: &mut Vec<Rect>,
1074) {
1075    if limits.should_stop() || depth >= limits.max_depth {
1076        limits.note_leaf();
1077        leaves.push(*rect);
1078        return;
1079    }
1080    if rect.start_row >= rect.end_row && rect.start_col >= rect.end_col {
1081        limits.note_leaf();
1082        leaves.push(*rect);
1083        return;
1084    }
1085    if let Some(gutter) = find_best_gutter(occupancy, rect, limits) {
1086        match gutter {
1087            Gutter::Row { start, end } => {
1088                if start > rect.start_row {
1089                    let upper = Rect {
1090                        start_row: rect.start_row,
1091                        end_row: start - 1,
1092                        start_col: rect.start_col,
1093                        end_col: rect.end_col,
1094                    };
1095                    split_rect(occupancy, &upper, depth + 1, limits, leaves);
1096                }
1097                if end < rect.end_row {
1098                    let lower = Rect {
1099                        start_row: end + 1,
1100                        end_row: rect.end_row,
1101                        start_col: rect.start_col,
1102                        end_col: rect.end_col,
1103                    };
1104                    split_rect(occupancy, &lower, depth + 1, limits, leaves);
1105                }
1106            }
1107            Gutter::Col { start, end } => {
1108                if start > rect.start_col {
1109                    let left = Rect {
1110                        start_row: rect.start_row,
1111                        end_row: rect.end_row,
1112                        start_col: rect.start_col,
1113                        end_col: start - 1,
1114                    };
1115                    split_rect(occupancy, &left, depth + 1, limits, leaves);
1116                }
1117                if end < rect.end_col {
1118                    let right = Rect {
1119                        start_row: rect.start_row,
1120                        end_row: rect.end_row,
1121                        start_col: end + 1,
1122                        end_col: rect.end_col,
1123                    };
1124                    split_rect(occupancy, &right, depth + 1, limits, leaves);
1125                }
1126            }
1127        }
1128        return;
1129    }
1130    limits.note_leaf();
1131    leaves.push(*rect);
1132}
1133
1134fn find_best_gutter(
1135    occupancy: &Occupancy,
1136    rect: &Rect,
1137    limits: &mut DetectLimits,
1138) -> Option<Gutter> {
1139    if limits.should_stop() {
1140        return None;
1141    }
1142    let (row_counts, col_counts) = occupancy.row_col_counts(rect);
1143    let width = rect.end_col - rect.start_col + 1;
1144    let height = rect.end_row - rect.start_row + 1;
1145
1146    let row_blank_runs = find_blank_runs(&row_counts, width);
1147    let col_blank_runs = find_blank_runs(&col_counts, height);
1148
1149    let mut best: Option<(Gutter, u32)> = None;
1150
1151    if let Some((start, end, len)) = row_blank_runs {
1152        let gutter = Gutter::Row {
1153            start: rect.start_row + start,
1154            end: rect.start_row + end,
1155        };
1156        best = Some((gutter, len));
1157    }
1158    if let Some((start, end, len)) = col_blank_runs {
1159        let gutter = Gutter::Col {
1160            start: rect.start_col + start,
1161            end: rect.start_col + end,
1162        };
1163        if best.map(|(_, l)| len > l).unwrap_or(true) {
1164            best = Some((gutter, len));
1165        }
1166    }
1167
1168    best.map(|(g, _)| g)
1169}
1170
1171fn find_blank_runs(counts: &[u32], span: u32) -> Option<(u32, u32, u32)> {
1172    if counts.is_empty() {
1173        return None;
1174    }
1175    let mut best_start = 0;
1176    let mut best_end = 0;
1177    let mut best_len = 0;
1178    let mut current_start = None;
1179    for (idx, count) in counts.iter().enumerate() {
1180        let is_blank = *count == 0 || (*count as f32 / span as f32) < 0.05;
1181        if is_blank {
1182            if current_start.is_none() {
1183                current_start = Some(idx as u32);
1184            }
1185        } else if let Some(start) = current_start.take() {
1186            let end = idx as u32 - 1;
1187            let len = end - start + 1;
1188            if len > best_len && start > 0 && end + 1 < counts.len() as u32 {
1189                best_len = len;
1190                best_start = start;
1191                best_end = end;
1192            }
1193        }
1194    }
1195    if let Some(start) = current_start {
1196        let end = counts.len() as u32 - 1;
1197        let len = end - start + 1;
1198        if len > best_len && start > 0 && end + 1 < counts.len() as u32 {
1199            best_len = len;
1200            best_start = start;
1201            best_end = end;
1202        }
1203    }
1204    if best_len >= 2 {
1205        Some((best_start, best_end, best_len))
1206    } else {
1207        None
1208    }
1209}
1210
1211fn trim_rect(occupancy: &Occupancy, rect: Rect, limits: &mut DetectLimits) -> Option<Rect> {
1212    let mut r = rect;
1213    loop {
1214        if limits.should_stop() {
1215            return Some(r);
1216        }
1217        let (row_counts, col_counts) = occupancy.row_col_counts(&r);
1218        let width = r.end_col - r.start_col + 1;
1219        let height = r.end_row - r.start_row + 1;
1220        let top_blank = row_counts
1221            .first()
1222            .map(|c| *c == 0 || (*c as f32 / width as f32) < 0.1)
1223            .unwrap_or(false);
1224        let bottom_blank = row_counts
1225            .last()
1226            .map(|c| *c == 0 || (*c as f32 / width as f32) < 0.1)
1227            .unwrap_or(false);
1228        let left_blank = col_counts
1229            .first()
1230            .map(|c| *c == 0 || (*c as f32 / height as f32) < 0.1)
1231            .unwrap_or(false);
1232        let right_blank = col_counts
1233            .last()
1234            .map(|c| *c == 0 || (*c as f32 / height as f32) < 0.1)
1235            .unwrap_or(false);
1236
1237        let mut changed = false;
1238        if top_blank && r.start_row < r.end_row {
1239            r.start_row += 1;
1240            changed = true;
1241        }
1242        if bottom_blank && r.end_row > r.start_row {
1243            r.end_row -= 1;
1244            changed = true;
1245        }
1246        if left_blank && r.start_col < r.end_col {
1247            r.start_col += 1;
1248            changed = true;
1249        }
1250        if right_blank && r.end_col > r.start_col {
1251            r.end_col -= 1;
1252            changed = true;
1253        }
1254
1255        if !changed {
1256            break;
1257        }
1258        if r.start_row > r.end_row || r.start_col > r.end_col {
1259            return None;
1260        }
1261    }
1262    Some(r)
1263}
1264
1265fn build_region(
1266    occupancy: &Occupancy,
1267    rect: &Rect,
1268    metrics: &SheetMetrics,
1269    id: u32,
1270) -> crate::model::DetectedRegion {
1271    let header_info = detect_headers(occupancy, rect);
1272    let stats = occupancy.stats_in_rect(rect);
1273    let (kind, confidence) = classify_region(rect, &stats, &header_info, metrics);
1274    let header_len = header_info.headers.len() as u32;
1275    let header_count = rect.end_col - rect.start_col + 1;
1276    let headers_truncated = header_len != header_count;
1277    crate::model::DetectedRegion {
1278        id,
1279        bounds: format!(
1280            "{}{}:{}{}",
1281            crate::utils::column_number_to_name(rect.start_col),
1282            rect.start_row,
1283            crate::utils::column_number_to_name(rect.end_col),
1284            rect.end_row
1285        ),
1286        header_row: header_info.header_row,
1287        headers: header_info.headers,
1288        header_count,
1289        headers_truncated,
1290        row_count: rect.end_row - rect.start_row + 1,
1291        classification: kind.clone(),
1292        region_kind: Some(kind),
1293        confidence,
1294    }
1295}
1296
1297#[derive(Debug, Default)]
1298struct HeaderInfo {
1299    header_row: Option<u32>,
1300    headers: Vec<String>,
1301    is_key_value: bool,
1302}
1303
1304fn is_key_value_layout(occupancy: &Occupancy, rect: &Rect) -> bool {
1305    let width = rect.end_col - rect.start_col + 1;
1306
1307    if width == 2 {
1308        return check_key_value_columns(occupancy, rect, rect.start_col, rect.start_col + 1);
1309    }
1310
1311    if width <= KV_MAX_WIDTH_FOR_DENSITY_CHECK {
1312        let rows_to_sample = (rect.end_row - rect.start_row + 1).min(KV_SAMPLE_ROWS);
1313        let density_threshold = (rows_to_sample as f32 * KV_DENSITY_THRESHOLD) as u32;
1314
1315        let mut col_densities: Vec<(u32, u32)> = Vec::new();
1316        for col in rect.start_col..=rect.end_col {
1317            let count = (rect.start_row..rect.start_row + rows_to_sample)
1318                .filter(|&row| occupancy.value_at(row, col).is_some())
1319                .count() as u32;
1320            if count >= density_threshold {
1321                col_densities.push((col, count));
1322            }
1323        }
1324
1325        if col_densities.len() == 2 {
1326            let label_col = col_densities[0].0;
1327            let value_col = col_densities[1].0;
1328            return check_key_value_columns(occupancy, rect, label_col, value_col);
1329        } else if col_densities.len() == 4 && width >= 4 {
1330            let pair1 =
1331                check_key_value_columns(occupancy, rect, col_densities[0].0, col_densities[1].0);
1332            let pair2 =
1333                check_key_value_columns(occupancy, rect, col_densities[2].0, col_densities[3].0);
1334            return pair1 && pair2;
1335        }
1336    }
1337
1338    false
1339}
1340
1341fn check_key_value_columns(
1342    occupancy: &Occupancy,
1343    rect: &Rect,
1344    label_col: u32,
1345    value_col: u32,
1346) -> bool {
1347    let mut label_value_pairs = 0u32;
1348    let rows_to_check = (rect.end_row - rect.start_row + 1).min(KV_CHECK_ROWS);
1349
1350    for row in rect.start_row..rect.start_row + rows_to_check {
1351        let first_col = occupancy.value_at(row, label_col);
1352        let second_col = occupancy.value_at(row, value_col);
1353
1354        if let (Some(crate::model::CellValue::Text(label)), Some(val)) = (first_col, second_col) {
1355            let label_looks_like_key = label.len() <= KV_MAX_LABEL_LEN
1356                && !label.chars().any(|c| c.is_ascii_digit())
1357                && label.contains(|c: char| c.is_alphabetic());
1358
1359            let value_is_data = matches!(
1360                val,
1361                crate::model::CellValue::Number(_) | crate::model::CellValue::Date(_)
1362            ) || matches!(val, crate::model::CellValue::Text(s) if s.len() > KV_MIN_TEXT_VALUE_LEN);
1363
1364            if label_looks_like_key && value_is_data {
1365                label_value_pairs += 1;
1366            }
1367        }
1368    }
1369
1370    label_value_pairs >= KV_MIN_PAIRS
1371        && label_value_pairs as f32 / rows_to_check as f32 >= KV_MIN_PAIR_RATIO
1372}
1373
1374fn header_data_penalty(s: &str) -> f32 {
1375    if s.is_empty() {
1376        return 0.0;
1377    }
1378    if s.len() > HEADER_LONG_STRING_PENALTY_THRESHOLD {
1379        return HEADER_LONG_STRING_PENALTY;
1380    }
1381    let first_char = s.chars().next().unwrap();
1382    let is_capitalized = first_char.is_uppercase();
1383    let has_lowercase = s.chars().skip(1).any(|c| c.is_lowercase());
1384    let is_all_caps = s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase());
1385    let has_digits = s.chars().any(|c| c.is_ascii_digit());
1386    let is_proper_noun =
1387        is_capitalized && has_lowercase && !is_all_caps && s.len() > HEADER_PROPER_NOUN_MIN_LEN;
1388
1389    let mut penalty = 0.0;
1390    if is_proper_noun {
1391        penalty += HEADER_PROPER_NOUN_PENALTY;
1392    }
1393    if has_digits && s.len() > HEADER_DIGIT_STRING_MIN_LEN {
1394        penalty += HEADER_DIGIT_STRING_PENALTY;
1395    }
1396    penalty
1397}
1398
1399fn detect_headers(occupancy: &Occupancy, rect: &Rect) -> HeaderInfo {
1400    if is_key_value_layout(occupancy, rect) {
1401        let mut headers = Vec::new();
1402        for col in rect.start_col..=rect.end_col {
1403            headers.push(crate::utils::column_number_to_name(col));
1404        }
1405        return HeaderInfo {
1406            header_row: None,
1407            headers,
1408            is_key_value: true,
1409        };
1410    }
1411
1412    let width = rect.end_col - rect.start_col + 1;
1413    if width > HEADER_MAX_COLUMNS {
1414        return HeaderInfo {
1415            header_row: None,
1416            headers: Vec::new(),
1417            is_key_value: false,
1418        };
1419    }
1420
1421    let mut candidates = Vec::new();
1422    let max_row = rect
1423        .start_row
1424        .saturating_add(HEADER_MAX_SCAN_ROWS)
1425        .min(rect.end_row);
1426    for row in rect.start_row..=max_row {
1427        let mut text = 0;
1428        let mut numbers = 0;
1429        let mut non_empty = 0;
1430        let mut unique = HashSet::new();
1431        let mut data_like_penalty: f32 = 0.0;
1432        let mut year_like_bonus: f32 = 0.0;
1433
1434        for col in rect.start_col..=rect.end_col {
1435            if let Some(val) = occupancy.value_at(row, col) {
1436                non_empty += 1;
1437                match val {
1438                    crate::model::CellValue::Text(s) => {
1439                        text += 1;
1440                        unique.insert(s.clone());
1441                        data_like_penalty += header_data_penalty(s);
1442                    }
1443                    crate::model::CellValue::Number(n) => {
1444                        if *n >= HEADER_YEAR_MIN && *n <= HEADER_YEAR_MAX && n.fract() == 0.0 {
1445                            year_like_bonus += HEADER_YEAR_LIKE_BONUS;
1446                            text += 1;
1447                        } else {
1448                            numbers += 1;
1449                        }
1450                    }
1451                    crate::model::CellValue::Bool(_) => text += 1,
1452                    crate::model::CellValue::Date(_) => {
1453                        data_like_penalty += HEADER_DATE_PENALTY;
1454                    }
1455                    crate::model::CellValue::Error(_) => {}
1456                }
1457            }
1458        }
1459        if non_empty == 0 {
1460            continue;
1461        }
1462        let score = text as f32 + unique.len() as f32 * HEADER_UNIQUE_BONUS
1463            - numbers as f32 * HEADER_NUMBER_PENALTY
1464            - data_like_penalty
1465            + year_like_bonus;
1466        candidates.push((row, score, text, non_empty));
1467    }
1468
1469    let is_single_col = rect.start_col == rect.end_col;
1470
1471    let header_candidates: Vec<&(u32, f32, u32, u32)> = candidates
1472        .iter()
1473        .filter(|(_, score, text, non_empty)| {
1474            *text >= 1
1475                && *text * 2 >= *non_empty
1476                && (!is_single_col || *score > HEADER_SINGLE_COL_MIN_SCORE)
1477        })
1478        .collect();
1479
1480    let best = header_candidates.iter().copied().max_by(|a, b| {
1481        a.1.partial_cmp(&b.1)
1482            .unwrap_or(Ordering::Equal)
1483            .then_with(|| b.0.cmp(&a.0))
1484    });
1485    let earliest = header_candidates
1486        .iter()
1487        .copied()
1488        .min_by(|a, b| a.0.cmp(&b.0));
1489
1490    let maybe_header = match (best, earliest) {
1491        (Some(best_row), Some(early_row)) => {
1492            if (best_row.1 - early_row.1).abs() <= HEADER_SCORE_TIE_THRESHOLD {
1493                Some(early_row.0)
1494            } else {
1495                Some(best_row.0)
1496            }
1497        }
1498        (Some(best_row), None) => Some(best_row.0),
1499        _ => None,
1500    };
1501
1502    let mut header_rows = Vec::new();
1503    if let Some(hr) = maybe_header {
1504        header_rows.push(hr);
1505        if hr < rect.end_row
1506            && let Some((_, score_next, text_next, non_empty_next)) =
1507                candidates.iter().find(|(r, _, _, _)| *r == hr + 1)
1508            && *text_next >= 1
1509            && *text_next * 2 >= *non_empty_next
1510            && *score_next
1511                >= HEADER_SECOND_ROW_MIN_SCORE_RATIO
1512                    * candidates
1513                        .iter()
1514                        .find(|(r, _, _, _)| *r == hr)
1515                        .map(|c| c.1)
1516                        .unwrap_or(0.0)
1517        {
1518            header_rows.push(hr + 1);
1519        }
1520    }
1521
1522    let mut headers = Vec::new();
1523    for col in rect.start_col..=rect.end_col {
1524        let mut parts = Vec::new();
1525        for hr in &header_rows {
1526            if let Some(val) = occupancy.value_at(*hr, col) {
1527                match val {
1528                    crate::model::CellValue::Text(s) if !s.trim().is_empty() => {
1529                        parts.push(s.trim().to_string())
1530                    }
1531                    crate::model::CellValue::Number(n) => parts.push(n.to_string()),
1532                    crate::model::CellValue::Bool(b) => parts.push(b.to_string()),
1533                    crate::model::CellValue::Date(d) => parts.push(d.clone()),
1534                    crate::model::CellValue::Error(e) => parts.push(e.clone()),
1535                    _ => {}
1536                }
1537            }
1538        }
1539        if parts.is_empty() {
1540            headers.push(crate::utils::column_number_to_name(col));
1541        } else {
1542            headers.push(parts.join(" / "));
1543        }
1544    }
1545
1546    HeaderInfo {
1547        header_row: header_rows.first().copied(),
1548        headers,
1549        is_key_value: false,
1550    }
1551}
1552
1553fn classify_region(
1554    rect: &Rect,
1555    stats: &RegionStats,
1556    header_info: &HeaderInfo,
1557    metrics: &SheetMetrics,
1558) -> (crate::model::RegionKind, f32) {
1559    let width = rect.end_col - rect.start_col + 1;
1560    let height = rect.end_row - rect.start_row + 1;
1561    let area = width.max(1) * height.max(1);
1562    let density = if area == 0 {
1563        0.0
1564    } else {
1565        stats.non_empty as f32 / area as f32
1566    };
1567    let formula_ratio = if stats.non_empty == 0 {
1568        0.0
1569    } else {
1570        stats.formulas as f32 / stats.non_empty as f32
1571    };
1572    let text_ratio = if stats.non_empty == 0 {
1573        0.0
1574    } else {
1575        stats.text as f32 / stats.non_empty as f32
1576    };
1577
1578    let mut kind = crate::model::RegionKind::Data;
1579    if formula_ratio > 0.25 && is_outputs_band(rect, metrics, height, width) {
1580        kind = crate::model::RegionKind::Outputs;
1581    } else if formula_ratio > 0.55 {
1582        kind = crate::model::RegionKind::Calculator;
1583    } else if height <= 3
1584        && width <= 4
1585        && text_ratio > 0.5
1586        && rect.end_row >= metrics.row_count.saturating_sub(3)
1587    {
1588        kind = crate::model::RegionKind::Metadata;
1589    } else if header_info.is_key_value
1590        || (formula_ratio < 0.25
1591            && stats.numbers > 0
1592            && stats.text > 0
1593            && text_ratio >= 0.3
1594            && (width <= 2 || (width <= 3 && header_info.header_row.is_none())))
1595    {
1596        kind = crate::model::RegionKind::Parameters;
1597    } else if height <= 4 && width <= 6 && formula_ratio < 0.2 && text_ratio > 0.4 && density < 0.5
1598    {
1599        kind = crate::model::RegionKind::Metadata;
1600    }
1601
1602    let mut confidence: f32 = 0.4;
1603    if header_info.header_row.is_some() {
1604        confidence += 0.2;
1605    }
1606    confidence += (density * 0.2).min(0.2);
1607    confidence += (formula_ratio * 0.2).min(0.2);
1608    if matches!(
1609        kind,
1610        crate::model::RegionKind::Parameters | crate::model::RegionKind::Metadata
1611    ) && width <= 4
1612    {
1613        confidence += 0.1;
1614    }
1615    if confidence > 1.0 {
1616        confidence = 1.0;
1617    }
1618
1619    (kind, confidence)
1620}
1621
1622fn is_outputs_band(rect: &Rect, metrics: &SheetMetrics, height: u32, width: u32) -> bool {
1623    let near_bottom = rect.end_row >= metrics.row_count.saturating_sub(6);
1624    let near_right = rect.end_col >= metrics.column_count.saturating_sub(3);
1625    let is_shallow = height <= 6;
1626    let is_narrow_at_edge = width <= 6 && near_right;
1627    let not_at_top_left = rect.start_row > 1 || rect.start_col > 1;
1628    let sheet_has_depth = metrics.row_count > 10 || metrics.column_count > 6;
1629    let is_band = (is_shallow && near_bottom) || is_narrow_at_edge;
1630    is_band && not_at_top_left && sheet_has_depth
1631}
1632
1633fn gather_named_ranges(
1634    sheet: &Worksheet,
1635    defined_names: &[DefinedName],
1636) -> Vec<NamedRangeDescriptor> {
1637    let name_str = sheet.get_name();
1638    defined_names
1639        .iter()
1640        .filter(|name| name.get_address().contains(name_str))
1641        .map(|name| NamedRangeDescriptor {
1642            name: name.get_name().to_string(),
1643            scope: if name.has_local_sheet_id() {
1644                Some(name_str.to_string())
1645            } else {
1646                None
1647            },
1648            refers_to: name.get_address(),
1649            kind: NamedItemKind::NamedRange,
1650            sheet_name: Some(name_str.to_string()),
1651            comment: None,
1652        })
1653        .collect()
1654}
1655
1656pub fn build_workbook_list(
1657    config: &Arc<ServerConfig>,
1658    filter: &WorkbookFilter,
1659) -> Result<WorkbookListResponse> {
1660    let mut descriptors = Vec::new();
1661
1662    if let Some(single) = config.single_workbook() {
1663        let metadata = fs::metadata(single)
1664            .with_context(|| format!("unable to read metadata for {:?}", single))?;
1665        let canonical = fs::canonicalize(single).unwrap_or_else(|_| single.to_path_buf());
1666        let id = WorkbookId(hash_path_identity(&canonical));
1667        let slug = single
1668            .file_stem()
1669            .map(|s| s.to_string_lossy().to_string())
1670            .unwrap_or_else(|| "workbook".to_string());
1671        let folder = derive_folder(config, single);
1672        let short_id = make_short_workbook_id(&slug, id.as_str());
1673        let caps = BackendCaps::xlsx();
1674
1675        if filter.matches(&slug, folder.as_deref(), single) {
1676            let relative = single
1677                .strip_prefix(&config.workspace_root)
1678                .unwrap_or(single);
1679            let descriptor = crate::model::WorkbookDescriptor {
1680                workbook_id: id,
1681                short_id,
1682                slug,
1683                folder,
1684                path: Some(path_to_forward_slashes(relative)),
1685                client_path: None,
1686                bytes: metadata.len(),
1687                last_modified: metadata
1688                    .modified()
1689                    .ok()
1690                    .and_then(system_time_to_rfc3339)
1691                    .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1692                revision_id: Some(hash_file_sha256_hex(single)?),
1693                caps: Some(caps),
1694            };
1695            descriptors.push(descriptor);
1696        }
1697
1698        return Ok(WorkbookListResponse {
1699            workbooks: descriptors,
1700            next_offset: None,
1701        });
1702    }
1703
1704    use walkdir::WalkDir;
1705
1706    for entry in WalkDir::new(&config.workspace_root) {
1707        let entry = entry?;
1708        if !entry.file_type().is_file() {
1709            continue;
1710        }
1711        let path = entry.path();
1712        if !has_supported_extension(&config.supported_extensions, path) {
1713            continue;
1714        }
1715        let metadata = entry.metadata()?;
1716        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1717        let id = WorkbookId(hash_path_identity(&canonical));
1718        let slug = path
1719            .file_stem()
1720            .map(|s| s.to_string_lossy().to_string())
1721            .unwrap_or_else(|| "workbook".to_string());
1722        let folder = derive_folder(config, path);
1723        let short_id = make_short_workbook_id(&slug, id.as_str());
1724        let caps = BackendCaps::xlsx();
1725
1726        if !filter.matches(&slug, folder.as_deref(), path) {
1727            continue;
1728        }
1729
1730        let relative = path.strip_prefix(&config.workspace_root).unwrap_or(path);
1731        let descriptor = crate::model::WorkbookDescriptor {
1732            workbook_id: id,
1733            short_id,
1734            slug,
1735            folder,
1736            path: Some(path_to_forward_slashes(relative)),
1737            client_path: None,
1738            bytes: metadata.len(),
1739            last_modified: metadata
1740                .modified()
1741                .ok()
1742                .and_then(system_time_to_rfc3339)
1743                .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1744            revision_id: Some(hash_file_sha256_hex(path)?),
1745            caps: Some(caps),
1746        };
1747        descriptors.push(descriptor);
1748    }
1749
1750    descriptors.sort_by(|a, b| a.slug.cmp(&b.slug));
1751
1752    Ok(WorkbookListResponse {
1753        workbooks: descriptors,
1754        next_offset: None,
1755    })
1756}
1757
1758fn derive_folder(config: &Arc<ServerConfig>, path: &Path) -> Option<String> {
1759    path.strip_prefix(&config.workspace_root)
1760        .ok()
1761        .and_then(|relative| relative.parent())
1762        .and_then(|parent| parent.file_name())
1763        .map(|os| os.to_string_lossy().to_string())
1764}
1765
1766fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
1767    path.extension()
1768        .and_then(|ext| ext.to_str())
1769        .map(|ext| {
1770            let lower = ext.to_ascii_lowercase();
1771            allowed.iter().any(|candidate| candidate == &lower)
1772        })
1773        .unwrap_or(false)
1774}