Skip to main content

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