Skip to main content

spreadsheet_kit/cli/commands/
read.rs

1use anyhow::{Context, Result, anyhow, bail};
2use serde_json::Value;
3use std::path::PathBuf;
4
5use crate::cli::{
6    FindValueMode, FormulaSort, LabelDirectionArg, LayoutModeArg, LayoutRenderArg,
7    RangeValuesFormatArg, SheetPageFormatArg, TableReadFormat, TableSampleModeArg,
8    TraceDirectionArg,
9};
10use crate::model::{
11    FindMode, FormulaParsePolicy, LabelDirection, LayoutMode, LayoutRender, SheetPageFormat,
12    TableOutputFormat, TraceCursor, TraceDirection,
13};
14use crate::runtime::stateless::StatelessRuntime;
15use crate::tools;
16use crate::tools::{
17    DescribeWorkbookParams, FindFormulaParams, FindValueParams, FormulaSortBy, FormulaTraceParams,
18    InspectCellsParams, LayoutPageParams, ListSheetsParams, ManifestStubParams, NamedRangesParams,
19    RangeValuesParams, ReadTableParams, SampleMode, ScanVolatilesParams, SheetFormulaMapParams,
20    SheetOverviewParams, SheetPageParams, SheetStatisticsParams, TableFilter, TableProfileParams,
21};
22
23// ---------------------------------------------------------------------------
24// Session resolution helper
25// ---------------------------------------------------------------------------
26
27/// Resolve the effective workbook path, optionally materializing from a session.
28///
29/// When `session` is `Some`, the session's current state is materialized to a
30/// temp file whose path is returned. The caller must keep the returned
31/// `NamedTempFile` alive for the duration of the read operation.
32///
33/// When `session` is `None`, the provided `file` path is returned as-is.
34pub fn resolve_file_or_session(
35    file: PathBuf,
36    session: Option<String>,
37    workspace: Option<PathBuf>,
38) -> Result<(PathBuf, Option<tempfile::NamedTempFile>)> {
39    match session {
40        Some(session_id) => {
41            let workspace_root = workspace
42                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
43            let store = crate::core::session_store::SessionStore::open(&workspace_root)?;
44            let handle = store.open_session(&session_id)?;
45            let bytes = handle.materialize()?;
46
47            let mut tmp = tempfile::Builder::new()
48                .suffix(".xlsx")
49                .tempfile()
50                .context("failed to create temp file for session read")?;
51            std::io::Write::write_all(&mut tmp, &bytes)
52                .context("failed to write materialized session to temp file")?;
53
54            let path = tmp.path().to_path_buf();
55            Ok((path, Some(tmp)))
56        }
57        None => Ok((file, None)),
58    }
59}
60
61const TRACE_DEPTH_MIN: u32 = 1;
62const TRACE_DEPTH_MAX: u32 = 5;
63const TRACE_PAGE_SIZE_MIN: usize = 5;
64const TRACE_PAGE_SIZE_MAX: usize = 200;
65
66const SHEET_PAGE_DEFAULT_START_ROW: u32 = 1;
67const SHEET_PAGE_DEFAULT_PAGE_SIZE: u32 = 50;
68const SHEET_PAGE_DEFAULT_INCLUDE_FORMULAS: bool = true;
69const SHEET_PAGE_DEFAULT_INCLUDE_STYLES: bool = false;
70const SHEET_PAGE_DEFAULT_INCLUDE_HEADER: bool = true;
71
72pub async fn list_sheets(file: PathBuf) -> Result<Value> {
73    let runtime = StatelessRuntime;
74    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
75    let response = tools::list_sheets(
76        state,
77        ListSheetsParams {
78            workbook_or_fork_id: workbook_id,
79            limit: None,
80            offset: None,
81            include_bounds: None,
82        },
83    )
84    .await?;
85    Ok(serde_json::to_value(response)?)
86}
87
88pub async fn sheet_overview(file: PathBuf, sheet: String) -> Result<Value> {
89    let runtime = StatelessRuntime;
90    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
91    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
92    let response = tools::sheet_overview(
93        state,
94        SheetOverviewParams {
95            workbook_or_fork_id: workbook_id,
96            sheet_name: sheet,
97            max_regions: None,
98            max_headers: None,
99            include_headers: None,
100        },
101    )
102    .await?;
103    Ok(serde_json::to_value(response)?)
104}
105
106pub async fn range_values(
107    file: PathBuf,
108    sheet: String,
109    ranges: Vec<String>,
110    format: Option<RangeValuesFormatArg>,
111    include_formulas: Option<bool>,
112) -> Result<Value> {
113    if ranges.is_empty() {
114        bail!("at least one range must be provided");
115    }
116    let runtime = StatelessRuntime;
117    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
118    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
119    let resolved_format = format
120        .map(map_range_values_format)
121        .unwrap_or(TableOutputFormat::Dense);
122    let response = tools::range_values(
123        state,
124        RangeValuesParams {
125            workbook_or_fork_id: workbook_id,
126            sheet_name: sheet,
127            ranges,
128            include_headers: None,
129            include_formulas,
130            format: Some(resolved_format),
131            page_size: None,
132        },
133    )
134    .await?;
135    Ok(serde_json::to_value(response)?)
136}
137
138pub async fn range_export(
139    file: PathBuf,
140    sheet: String,
141    range: String,
142    format: String,
143    output: Option<String>,
144    include_formulas: Option<bool>,
145) -> Result<Value> {
146    let is_csv = format == "csv";
147    let is_grid = format == "grid";
148    if !is_csv && !is_grid && format != "json" {
149        bail!("unsupported format: {}", format);
150    }
151
152    let runtime = StatelessRuntime;
153    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
154    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
155
156    if is_grid {
157        let payload = tools::grid_export(
158            state,
159            tools::GridExportParams {
160                workbook_or_fork_id: workbook_id,
161                sheet_name: sheet,
162                range,
163            },
164        )
165        .await?;
166
167        if let Some(out_path) = output {
168            let json_str = serde_json::to_string_pretty(&payload)?;
169            if out_path == "-" {
170                print!("{}", json_str);
171            } else {
172                std::fs::write(&out_path, json_str)?;
173                return Ok(serde_json::json!({ "status": "ok", "path": out_path }));
174            }
175            std::process::exit(0);
176        }
177
178        return Ok(serde_json::to_value(payload)?);
179    }
180
181    let table_format = if is_csv {
182        TableOutputFormat::Csv
183    } else {
184        TableOutputFormat::Json
185    };
186
187    let mut response = tools::range_values(
188        state,
189        RangeValuesParams {
190            workbook_or_fork_id: workbook_id,
191            sheet_name: sheet,
192            ranges: vec![range],
193            include_headers: None,
194            include_formulas,
195            format: Some(table_format),
196            page_size: None,
197        },
198    )
199    .await?;
200
201    if let Some(mut first_entry) = response.values.pop() {
202        if is_csv {
203            let csv_str = first_entry.csv.take().unwrap_or_default();
204            if let Some(out_path) = output {
205                if out_path == "-" {
206                    print!("{}", csv_str);
207                } else {
208                    std::fs::write(&out_path, csv_str)?;
209                    return Ok(serde_json::json!({ "status": "ok", "path": out_path }));
210                }
211            } else {
212                print!("{}", csv_str);
213            }
214            std::process::exit(0);
215        }
216
217        if let Some(out_path) = output {
218            let json_str = serde_json::to_string_pretty(&first_entry)?;
219            if out_path == "-" {
220                println!("{}", json_str);
221            } else {
222                std::fs::write(&out_path, json_str)?;
223                return Ok(serde_json::json!({ "status": "ok", "path": out_path }));
224            }
225            std::process::exit(0);
226        }
227
228        return Ok(serde_json::to_value(first_entry)?);
229    }
230
231    bail!("no data returned from range-values");
232}
233
234pub async fn inspect_cells(
235    file: PathBuf,
236    sheet: String,
237    targets: Vec<String>,
238    include_empty: bool,
239    budget: Option<u32>,
240) -> Result<Value> {
241    if let Some(b) = budget
242        && !(1..=200).contains(&b)
243    {
244        bail!("--budget must be between 1 and 200 (got {b})");
245    }
246    let runtime = StatelessRuntime;
247    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
248    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
249    let response = tools::inspect_cells(
250        state,
251        InspectCellsParams {
252            workbook_or_fork_id: workbook_id,
253            sheet_name: sheet,
254            targets,
255            include_empty: Some(include_empty),
256            budget,
257        },
258    )
259    .await?;
260    Ok(serde_json::to_value(response)?)
261}
262
263#[allow(clippy::too_many_arguments)]
264pub async fn sheet_page(
265    file: PathBuf,
266    sheet: String,
267    start_row: Option<u32>,
268    page_size: Option<u32>,
269    columns: Option<Vec<String>>,
270    columns_by_header: Option<Vec<String>>,
271    include_formulas: Option<bool>,
272    include_styles: Option<bool>,
273    include_header: Option<bool>,
274    format: SheetPageFormatArg,
275) -> Result<Value> {
276    validate_sheet_page_arguments(page_size, columns.as_ref())?;
277
278    let runtime = StatelessRuntime;
279    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
280    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
281    let response = tools::sheet_page(
282        state,
283        SheetPageParams {
284            workbook_or_fork_id: workbook_id,
285            sheet_name: sheet,
286            start_row: start_row.unwrap_or(SHEET_PAGE_DEFAULT_START_ROW),
287            page_size: page_size.unwrap_or(SHEET_PAGE_DEFAULT_PAGE_SIZE),
288            columns,
289            columns_by_header,
290            include_formulas: include_formulas.unwrap_or(SHEET_PAGE_DEFAULT_INCLUDE_FORMULAS),
291            include_styles: include_styles.unwrap_or(SHEET_PAGE_DEFAULT_INCLUDE_STYLES),
292            include_header: include_header.unwrap_or(SHEET_PAGE_DEFAULT_INCLUDE_HEADER),
293            format: Some(map_sheet_page_format(format)),
294        },
295    )
296    .await?;
297    Ok(serde_json::to_value(response)?)
298}
299
300pub async fn describe(file: PathBuf) -> Result<Value> {
301    let runtime = StatelessRuntime;
302    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
303    let response = tools::describe_workbook(
304        state,
305        DescribeWorkbookParams {
306            workbook_or_fork_id: workbook_id,
307        },
308    )
309    .await?;
310    Ok(serde_json::to_value(response)?)
311}
312
313#[allow(clippy::too_many_arguments)]
314pub async fn read_table(
315    file: PathBuf,
316    sheet: Option<String>,
317    range: Option<String>,
318    table_name: Option<String>,
319    region_id: Option<u32>,
320    limit: Option<u32>,
321    offset: Option<u32>,
322    sample_mode: Option<TableSampleModeArg>,
323    filters_json: Option<String>,
324    filters_file: Option<PathBuf>,
325    format: Option<TableReadFormat>,
326) -> Result<Value> {
327    validate_read_table_arguments(limit, offset, sample_mode)?;
328    let filters = parse_table_filters(filters_json, filters_file)?;
329
330    let runtime = StatelessRuntime;
331    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
332    let sheet_name = match sheet {
333        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
334        None => None,
335    };
336    let response = tools::read_table(
337        state,
338        ReadTableParams {
339            workbook_or_fork_id: workbook_id,
340            sheet_name,
341            table_name,
342            region_id,
343            range,
344            header_row: None,
345            header_rows: None,
346            columns: None,
347            filters,
348            sample_mode: sample_mode.map(map_table_sample_mode),
349            limit,
350            offset,
351            format: format.map(map_table_read_format),
352            include_headers: None,
353            include_types: None,
354        },
355    )
356    .await?;
357    Ok(serde_json::to_value(response)?)
358}
359
360pub async fn find_value(
361    file: PathBuf,
362    query: String,
363    sheet: Option<String>,
364    mode: Option<FindValueMode>,
365    label_direction: Option<LabelDirectionArg>,
366) -> Result<Value> {
367    let runtime = StatelessRuntime;
368    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
369    let sheet_name = match sheet {
370        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
371        None => None,
372    };
373
374    let mapped_mode = mode.map(map_find_value_mode);
375    let label = if matches!(mapped_mode, Some(FindMode::Label)) {
376        Some(query.clone())
377    } else {
378        None
379    };
380
381    let response = tools::find_value(
382        state,
383        FindValueParams {
384            workbook_or_fork_id: workbook_id,
385            query,
386            label,
387            mode: mapped_mode,
388            direction: label_direction.map(map_label_direction),
389            sheet_name,
390            ..FindValueParams::default()
391        },
392    )
393    .await?;
394    Ok(serde_json::to_value(response)?)
395}
396
397pub async fn named_ranges(
398    file: PathBuf,
399    sheet: Option<String>,
400    name_prefix: Option<String>,
401) -> Result<Value> {
402    let runtime = StatelessRuntime;
403    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
404    let sheet_name = match sheet {
405        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
406        None => None,
407    };
408
409    let response = tools::named_ranges(
410        state,
411        NamedRangesParams {
412            workbook_or_fork_id: workbook_id,
413            sheet_name,
414            name_prefix,
415        },
416    )
417    .await?;
418    Ok(serde_json::to_value(response)?)
419}
420
421pub async fn find_formula(
422    file: PathBuf,
423    query: String,
424    sheet: Option<String>,
425    limit: Option<u32>,
426    offset: Option<u32>,
427) -> Result<Value> {
428    validate_positive_limit(limit, "--limit")?;
429
430    let runtime = StatelessRuntime;
431    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
432    let sheet_name = match sheet {
433        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
434        None => None,
435    };
436
437    let response = tools::find_formula(
438        state,
439        FindFormulaParams {
440            workbook_or_fork_id: workbook_id,
441            query,
442            sheet_name,
443            case_sensitive: false,
444            include_context: false,
445            limit: limit.unwrap_or(50),
446            offset: offset.unwrap_or(0),
447            context_rows: None,
448            context_cols: None,
449        },
450    )
451    .await?;
452    Ok(serde_json::to_value(response)?)
453}
454
455pub async fn scan_volatiles(
456    file: PathBuf,
457    sheet: Option<String>,
458    limit: Option<u32>,
459    offset: Option<u32>,
460    formula_parse_policy: Option<FormulaParsePolicy>,
461) -> Result<Value> {
462    validate_positive_limit(limit, "--limit")?;
463
464    let runtime = StatelessRuntime;
465    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
466    let sheet_name = match sheet {
467        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
468        None => None,
469    };
470
471    let response = tools::scan_volatiles(
472        state,
473        ScanVolatilesParams {
474            workbook_or_fork_id: workbook_id,
475            sheet_name,
476            summary_only: None,
477            include_addresses: None,
478            addresses_limit: None,
479            limit,
480            offset,
481            formula_parse_policy,
482        },
483    )
484    .await?;
485    Ok(serde_json::to_value(response)?)
486}
487
488pub async fn sheet_statistics(file: PathBuf, sheet: String) -> Result<Value> {
489    let runtime = StatelessRuntime;
490    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
491    let sheet_name = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
492
493    let response = tools::sheet_statistics(
494        state,
495        SheetStatisticsParams {
496            workbook_or_fork_id: workbook_id,
497            sheet_name,
498            sample_rows: None,
499            summary_only: None,
500        },
501    )
502    .await?;
503    Ok(serde_json::to_value(response)?)
504}
505
506pub async fn formula_map(
507    file: PathBuf,
508    sheet: String,
509    limit: Option<u32>,
510    sort_by: Option<FormulaSort>,
511    formula_parse_policy: Option<FormulaParsePolicy>,
512) -> Result<Value> {
513    let runtime = StatelessRuntime;
514    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
515    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
516    let response = tools::sheet_formula_map(
517        state,
518        SheetFormulaMapParams {
519            workbook_or_fork_id: workbook_id,
520            sheet_name: sheet,
521            range: None,
522            expand: false,
523            limit,
524            sort_by: sort_by.map(map_formula_sort),
525            summary_only: None,
526            include_addresses: None,
527            addresses_limit: None,
528            formula_parse_policy,
529        },
530    )
531    .await?;
532    Ok(serde_json::to_value(response)?)
533}
534
535#[allow(clippy::too_many_arguments)]
536pub async fn formula_trace(
537    file: PathBuf,
538    sheet: String,
539    cell: String,
540    direction: TraceDirectionArg,
541    depth: Option<u32>,
542    page_size: Option<usize>,
543    cursor_depth: Option<u32>,
544    cursor_offset: Option<usize>,
545    formula_parse_policy: Option<FormulaParsePolicy>,
546) -> Result<Value> {
547    validate_formula_trace_arguments(depth, page_size)?;
548    let cursor = build_trace_cursor(cursor_depth, cursor_offset)?;
549
550    let runtime = StatelessRuntime;
551    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
552    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
553    let response = tools::formula_trace(
554        state,
555        FormulaTraceParams {
556            workbook_or_fork_id: workbook_id,
557            sheet_name: sheet,
558            cell_address: cell,
559            direction: map_trace_direction(direction),
560            depth,
561            limit: None,
562            page_size,
563            cursor,
564            formula_parse_policy,
565        },
566    )
567    .await?;
568    Ok(serde_json::to_value(response)?)
569}
570
571pub async fn table_profile(file: PathBuf, sheet: Option<String>) -> Result<Value> {
572    let runtime = StatelessRuntime;
573    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
574    let sheet_name = match sheet {
575        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
576        None => None,
577    };
578    let response = tools::table_profile(
579        state,
580        TableProfileParams {
581            workbook_or_fork_id: workbook_id,
582            sheet_name,
583            region_id: None,
584            table_name: None,
585            sample_mode: None,
586            sample_size: None,
587            summary_only: None,
588        },
589    )
590    .await?;
591    Ok(serde_json::to_value(response)?)
592}
593
594fn map_table_read_format(format: TableReadFormat) -> TableOutputFormat {
595    match format {
596        TableReadFormat::Json => TableOutputFormat::Json,
597        TableReadFormat::Values => TableOutputFormat::Values,
598        TableReadFormat::Csv => TableOutputFormat::Csv,
599    }
600}
601
602fn map_range_values_format(format: RangeValuesFormatArg) -> TableOutputFormat {
603    match format {
604        RangeValuesFormatArg::Json => TableOutputFormat::Json,
605        RangeValuesFormatArg::Values => TableOutputFormat::Values,
606        RangeValuesFormatArg::Csv => TableOutputFormat::Csv,
607        RangeValuesFormatArg::Dense => TableOutputFormat::Dense,
608        RangeValuesFormatArg::Rows => TableOutputFormat::Rows,
609    }
610}
611
612fn map_sheet_page_format(format: SheetPageFormatArg) -> SheetPageFormat {
613    match format {
614        SheetPageFormatArg::Full => SheetPageFormat::Full,
615        SheetPageFormatArg::Compact => SheetPageFormat::Compact,
616        SheetPageFormatArg::ValuesOnly => SheetPageFormat::ValuesOnly,
617    }
618}
619
620fn map_table_sample_mode(mode: TableSampleModeArg) -> SampleMode {
621    match mode {
622        TableSampleModeArg::First => SampleMode::First,
623        TableSampleModeArg::Last => SampleMode::Last,
624        TableSampleModeArg::Distributed => SampleMode::Distributed,
625    }
626}
627
628fn map_find_value_mode(mode: FindValueMode) -> FindMode {
629    match mode {
630        FindValueMode::Value => FindMode::Value,
631        FindValueMode::Label => FindMode::Label,
632    }
633}
634
635fn map_label_direction(direction: LabelDirectionArg) -> LabelDirection {
636    match direction {
637        LabelDirectionArg::Right => LabelDirection::Right,
638        LabelDirectionArg::Below => LabelDirection::Below,
639        LabelDirectionArg::Any => LabelDirection::Any,
640    }
641}
642
643fn map_formula_sort(sort: FormulaSort) -> FormulaSortBy {
644    match sort {
645        FormulaSort::Complexity => FormulaSortBy::Complexity,
646        FormulaSort::Count => FormulaSortBy::Count,
647    }
648}
649
650fn map_trace_direction(direction: TraceDirectionArg) -> TraceDirection {
651    match direction {
652        TraceDirectionArg::Precedents => TraceDirection::Precedents,
653        TraceDirectionArg::Dependents => TraceDirection::Dependents,
654    }
655}
656
657fn validate_sheet_page_arguments(
658    page_size: Option<u32>,
659    columns: Option<&Vec<String>>,
660) -> Result<()> {
661    if matches!(page_size, Some(0)) {
662        return Err(invalid_argument("--page-size must be at least 1"));
663    }
664
665    validate_sheet_page_columns(columns)?;
666    Ok(())
667}
668
669fn validate_sheet_page_columns(columns: Option<&Vec<String>>) -> Result<()> {
670    let Some(columns) = columns else {
671        return Ok(());
672    };
673
674    for raw_spec in columns {
675        let spec = raw_spec.trim();
676        if spec.is_empty() {
677            return Err(invalid_argument("invalid column spec: ''"));
678        }
679
680        let (start, end) = spec.split_once(':').unwrap_or((spec, spec));
681        if !is_valid_column_token(start) || !is_valid_column_token(end) {
682            return Err(invalid_argument(format!(
683                "invalid column spec: '{raw_spec}'"
684            )));
685        }
686
687        let start_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(start);
688        let end_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(end);
689        if start_idx == 0 || end_idx == 0 {
690            return Err(invalid_argument(format!(
691                "invalid column spec: '{raw_spec}'"
692            )));
693        }
694    }
695
696    Ok(())
697}
698
699fn is_valid_column_token(token: &str) -> bool {
700    let token = token.trim();
701    !token.is_empty() && token.chars().all(|ch| ch.is_ascii_alphabetic())
702}
703
704fn validate_positive_limit(limit: Option<u32>, flag_name: &'static str) -> Result<()> {
705    if matches!(limit, Some(0)) {
706        return Err(invalid_argument(format!("{flag_name} must be at least 1")));
707    }
708    Ok(())
709}
710
711fn validate_read_table_arguments(
712    limit: Option<u32>,
713    offset: Option<u32>,
714    sample_mode: Option<TableSampleModeArg>,
715) -> Result<()> {
716    validate_positive_limit(limit, "--limit")?;
717
718    if offset.unwrap_or(0) > 0
719        && let Some(TableSampleModeArg::Last | TableSampleModeArg::Distributed) = sample_mode
720    {
721        return Err(invalid_argument(
722            "--offset greater than 0 requires --sample-mode first",
723        ));
724    }
725
726    Ok(())
727}
728
729fn parse_table_filters(
730    filters_json: Option<String>,
731    filters_file: Option<PathBuf>,
732) -> Result<Option<Vec<TableFilter>>> {
733    match (filters_json, filters_file) {
734        (Some(_), Some(_)) => Err(invalid_argument(
735            "--filters-json and --filters-file are mutually exclusive",
736        )),
737        (Some(raw), None) => parse_table_filters_payload(&raw, "--filters-json").map(Some),
738        (None, Some(path)) => {
739            let raw = std::fs::read_to_string(&path).map_err(|err| {
740                invalid_argument(format!(
741                    "failed to read --filters-file '{}': {}",
742                    path.display(),
743                    err
744                ))
745            })?;
746            parse_table_filters_payload(&raw, "--filters-file").map(Some)
747        }
748        (None, None) => Ok(None),
749    }
750}
751
752fn parse_table_filters_payload(raw: &str, source: &str) -> Result<Vec<TableFilter>> {
753    serde_json::from_str(raw).map_err(|err| {
754        invalid_argument(format!(
755            "{source} must be a valid JSON array of filters: {err}"
756        ))
757    })
758}
759
760fn validate_formula_trace_arguments(depth: Option<u32>, page_size: Option<usize>) -> Result<()> {
761    if let Some(depth) = depth
762        && !(TRACE_DEPTH_MIN..=TRACE_DEPTH_MAX).contains(&depth)
763    {
764        return Err(invalid_argument(format!(
765            "--depth must be between {TRACE_DEPTH_MIN} and {TRACE_DEPTH_MAX}"
766        )));
767    }
768
769    if let Some(page_size) = page_size
770        && !(TRACE_PAGE_SIZE_MIN..=TRACE_PAGE_SIZE_MAX).contains(&page_size)
771    {
772        return Err(invalid_argument(format!(
773            "--page-size must be between {TRACE_PAGE_SIZE_MIN} and {TRACE_PAGE_SIZE_MAX}"
774        )));
775    }
776
777    Ok(())
778}
779
780fn build_trace_cursor(
781    cursor_depth: Option<u32>,
782    cursor_offset: Option<usize>,
783) -> Result<Option<TraceCursor>> {
784    match (cursor_depth, cursor_offset) {
785        (None, None) => Ok(None),
786        (Some(depth), Some(offset)) => {
787            if depth < 1 {
788                return Err(invalid_argument("--cursor-depth must be at least 1"));
789            }
790            Ok(Some(TraceCursor { depth, offset }))
791        }
792        (Some(_), None) | (None, Some(_)) => Err(invalid_argument(
793            "--cursor-depth and --cursor-offset must be provided together",
794        )),
795    }
796}
797
798fn invalid_argument(message: impl Into<String>) -> anyhow::Error {
799    anyhow!("invalid argument: {}", message.into())
800}
801
802async fn resolve_sheet_name(
803    state: &std::sync::Arc<crate::state::AppState>,
804    workbook_id: &crate::model::WorkbookId,
805    requested: &str,
806) -> Result<String> {
807    let response = tools::list_sheets(
808        state.clone(),
809        ListSheetsParams {
810            workbook_or_fork_id: workbook_id.clone(),
811            limit: None,
812            offset: None,
813            include_bounds: None,
814        },
815    )
816    .await?;
817
818    let Some(exact) = response.sheets.iter().find(|entry| entry.name == requested) else {
819        if let Some(case_insensitive) = response
820            .sheets
821            .iter()
822            .find(|entry| entry.name.eq_ignore_ascii_case(requested))
823        {
824            return Ok(case_insensitive.name.clone());
825        }
826
827        let best = response
828            .sheets
829            .iter()
830            .min_by_key(|entry| levenshtein(requested, &entry.name))
831            .map(|entry| entry.name.clone());
832        if let Some(suggestion) = best {
833            bail!(
834                "sheet '{}' not found; did you mean '{}' ?",
835                requested,
836                suggestion
837            );
838        }
839        bail!("sheet '{}' not found", requested);
840    };
841
842    Ok(exact.name.clone())
843}
844
845fn levenshtein(left: &str, right: &str) -> usize {
846    if left == right {
847        return 0;
848    }
849    if left.is_empty() {
850        return right.chars().count();
851    }
852    if right.is_empty() {
853        return left.chars().count();
854    }
855
856    let left_chars = left.chars().collect::<Vec<_>>();
857    let right_chars = right.chars().collect::<Vec<_>>();
858
859    let mut prev = (0..=right_chars.len()).collect::<Vec<_>>();
860    let mut curr = vec![0usize; right_chars.len() + 1];
861
862    for (i, lc) in left_chars.iter().enumerate() {
863        curr[0] = i + 1;
864        for (j, rc) in right_chars.iter().enumerate() {
865            let cost = usize::from(lc != rc);
866            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
867        }
868        std::mem::swap(&mut prev, &mut curr);
869    }
870
871    prev[right_chars.len()]
872}
873
874pub async fn run_manifest(
875    file: PathBuf,
876    manifest: PathBuf,
877    inputs_arg: Option<String>,
878    rng_seed: Option<u64>,
879    freeze_volatile: bool,
880) -> Result<Value> {
881    let manifest_yaml = std::fs::read_to_string(&manifest).context(format!(
882        "failed to read manifest from '{}'",
883        manifest.display()
884    ))?;
885
886    let mut parsed_inputs = std::collections::BTreeMap::new();
887    if let Some(inputs_str) = inputs_arg {
888        let json_str = if let Some(inputs_path) = inputs_str.strip_prefix('@') {
889            std::fs::read_to_string(inputs_path)
890                .context(format!("failed to read inputs file '{}'", inputs_path))?
891        } else {
892            inputs_str
893        };
894        let parsed: serde_json::Value =
895            serde_json::from_str(&json_str).context("failed to parse inputs as JSON")?;
896
897        if let serde_json::Value::Object(map) = parsed {
898            parsed_inputs = map.into_iter().collect();
899        } else {
900            bail!("--inputs JSON must be an object keyed by port id");
901        }
902    }
903
904    let runtime = StatelessRuntime;
905    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
906
907    let response = tools::execute_manifest(
908        state,
909        tools::ExecuteManifestParams {
910            workbook_or_fork_id: workbook_id,
911            manifest_yaml,
912            inputs: parsed_inputs,
913            rng_seed,
914            freeze_volatile,
915        },
916    )
917    .await?;
918
919    Ok(serde_json::to_value(response)?)
920}
921
922pub async fn sheetport_run(
923    file: PathBuf,
924    manifest: PathBuf,
925    inputs_arg: Option<String>,
926    rng_seed: Option<u64>,
927    freeze_volatile: bool,
928) -> Result<Value> {
929    run_manifest(file, manifest, inputs_arg, rng_seed, freeze_volatile).await
930}
931
932pub async fn sheetport_manifest_candidates(
933    file: PathBuf,
934    sheet_filter: Option<String>,
935) -> Result<Value> {
936    let runtime = StatelessRuntime;
937    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
938    let response = tools::get_manifest_stub(
939        state,
940        ManifestStubParams {
941            workbook_or_fork_id: workbook_id,
942            sheet_filter,
943        },
944    )
945    .await?;
946    Ok(serde_json::to_value(response)?)
947}
948
949#[cfg(feature = "recalc-formualizer")]
950pub fn sheetport_manifest_schema() -> Result<Value> {
951    let schema = formualizer::sheetport_spec::schema_json();
952    let schema_value: serde_json::Value =
953        serde_json::from_str(schema).context("failed to parse bundled SheetPort JSON schema")?;
954    Ok(schema_value)
955}
956
957#[cfg(feature = "recalc-formualizer")]
958pub fn sheetport_manifest_validate(manifest: PathBuf) -> Result<Value> {
959    let manifest_yaml = std::fs::read_to_string(&manifest).context(format!(
960        "failed to read manifest from '{}'",
961        manifest.display()
962    ))?;
963
964    let parsed = formualizer::sheetport_spec::Manifest::from_yaml_str(&manifest_yaml);
965    let response = match parsed {
966        Ok(manifest_obj) => match manifest_obj.validate() {
967            Ok(()) => serde_json::json!({
968                "valid": true,
969                "issues": []
970            }),
971            Err(err) => serde_json::json!({
972                "valid": false,
973                "issues": err.issues(),
974            }),
975        },
976        Err(err) => serde_json::json!({
977            "valid": false,
978            "issues": [{
979                "path": "<document>",
980                "message": err.to_string(),
981            }],
982        }),
983    };
984
985    Ok(response)
986}
987
988#[cfg(feature = "recalc-formualizer")]
989pub fn sheetport_manifest_normalize(manifest: PathBuf, output: Option<PathBuf>) -> Result<Value> {
990    let manifest_yaml = std::fs::read_to_string(&manifest).context(format!(
991        "failed to read manifest from '{}'",
992        manifest.display()
993    ))?;
994
995    let mut manifest_obj = formualizer::sheetport_spec::Manifest::from_yaml_str(&manifest_yaml)
996        .map_err(|err| anyhow!("failed to parse manifest YAML: {}", err))?;
997    manifest_obj.normalize();
998    let normalized_yaml = manifest_obj
999        .to_yaml()
1000        .map_err(|err| anyhow!("failed to serialize normalized YAML: {}", err))?;
1001
1002    if let Some(output_path) = output {
1003        std::fs::write(&output_path, &normalized_yaml).context(format!(
1004            "failed to write normalized manifest to '{}'",
1005            output_path.display()
1006        ))?;
1007        Ok(serde_json::json!({
1008            "written": true,
1009            "output_path": output_path,
1010        }))
1011    } else {
1012        Ok(serde_json::json!({
1013            "written": false,
1014            "manifest_yaml": normalized_yaml,
1015        }))
1016    }
1017}
1018
1019#[cfg(feature = "recalc-formualizer")]
1020pub async fn sheetport_bind_check(file: PathBuf, manifest: PathBuf) -> Result<Value> {
1021    use formualizer::workbook::SpreadsheetReader;
1022
1023    let manifest_yaml = std::fs::read_to_string(&manifest).context(format!(
1024        "failed to read manifest from '{}'",
1025        manifest.display()
1026    ))?;
1027
1028    let manifest_obj = match formualizer::sheetport_spec::Manifest::from_yaml_str(&manifest_yaml) {
1029        Ok(manifest_obj) => manifest_obj,
1030        Err(err) => {
1031            return Ok(serde_json::json!({
1032                "ok": false,
1033                "stage": "parse",
1034                "error": err.to_string(),
1035            }));
1036        }
1037    };
1038
1039    if let Err(err) = manifest_obj.validate() {
1040        return Ok(serde_json::json!({
1041            "ok": false,
1042            "stage": "validate",
1043            "issues": err.issues(),
1044        }));
1045    }
1046
1047    let runtime = StatelessRuntime;
1048    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
1049    let workbook_ctx = state.open_workbook(&workbook_id).await?;
1050
1051    let workbook_bytes = std::fs::read(&workbook_ctx.path)?;
1052    let adapter = formualizer::workbook::UmyaAdapter::open_bytes(workbook_bytes)
1053        .or_else(|_| formualizer::workbook::UmyaAdapter::open_path(&workbook_ctx.path))
1054        .map_err(|e| anyhow!("failed to open workbook adapter: {}", e))?;
1055
1056    let workbook = formualizer::workbook::Workbook::from_reader(
1057        adapter,
1058        formualizer::workbook::LoadStrategy::EagerAll,
1059        formualizer::workbook::WorkbookConfig::ephemeral(),
1060    )
1061    .map_err(|e| anyhow!("failed to load workbook: {}", e))?;
1062
1063    match formualizer::sheetport::SheetPortSession::new(workbook, manifest_obj) {
1064        Ok(session) => Ok(serde_json::json!({
1065            "ok": true,
1066            "workbook_id": workbook_id,
1067            "binding_count": session.bindings().len(),
1068        })),
1069        Err(err) => Ok(serde_json::json!({
1070            "ok": false,
1071            "stage": "bind",
1072            "error": err.to_string(),
1073        })),
1074    }
1075}
1076
1077#[cfg(not(feature = "recalc-formualizer"))]
1078pub fn sheetport_manifest_schema() -> Result<Value> {
1079    Err(anyhow!(
1080        "sheetport commands require the 'recalc-formualizer' feature"
1081    ))
1082}
1083
1084#[cfg(not(feature = "recalc-formualizer"))]
1085pub fn sheetport_manifest_validate(_manifest: PathBuf) -> Result<Value> {
1086    Err(anyhow!(
1087        "sheetport commands require the 'recalc-formualizer' feature"
1088    ))
1089}
1090
1091#[cfg(not(feature = "recalc-formualizer"))]
1092pub fn sheetport_manifest_normalize(_manifest: PathBuf, _output: Option<PathBuf>) -> Result<Value> {
1093    Err(anyhow!(
1094        "sheetport commands require the 'recalc-formualizer' feature"
1095    ))
1096}
1097
1098#[cfg(not(feature = "recalc-formualizer"))]
1099pub async fn sheetport_bind_check(_file: PathBuf, _manifest: PathBuf) -> Result<Value> {
1100    Err(anyhow!(
1101        "sheetport commands require the 'recalc-formualizer' feature"
1102    ))
1103}
1104
1105#[allow(clippy::too_many_arguments)]
1106pub async fn layout_page(
1107    file: PathBuf,
1108    sheet: String,
1109    range: Option<String>,
1110    mode: Option<LayoutModeArg>,
1111    max_col_width: Option<u32>,
1112    fit_columns: bool,
1113    skip_empty_columns_trim: bool,
1114    render: Option<LayoutRenderArg>,
1115) -> Result<Value> {
1116    let runtime = StatelessRuntime;
1117    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
1118    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
1119    let response = tools::layout_page(
1120        state,
1121        LayoutPageParams {
1122            workbook_or_fork_id: workbook_id,
1123            sheet_name: sheet,
1124            range,
1125            mode: mode.map(|m| match m {
1126                LayoutModeArg::Values => LayoutMode::Values,
1127                LayoutModeArg::Formulas => LayoutMode::Formulas,
1128            }),
1129            max_col_width,
1130            fit_columns: Some(fit_columns),
1131            trim_empty_columns: Some(!skip_empty_columns_trim),
1132            render: render.map(|r| match r {
1133                LayoutRenderArg::Json => LayoutRender::Json,
1134                LayoutRenderArg::Ascii => LayoutRender::Ascii,
1135                LayoutRenderArg::Both => LayoutRender::Both,
1136            }),
1137        },
1138    )
1139    .await?;
1140    Ok(serde_json::to_value(response)?)
1141}