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
23pub 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}