From e736f2aa30aa5f1ad86564833d3562c82b991c81 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Tue, 21 Apr 2026 18:36:44 +0800 Subject: [PATCH 1/5] feat: add selective read options to read rows --- Cargo.lock | 41 +++- Cargo.toml | 1 + src/cli/args.rs | 20 ++ src/cli/read.rs | 362 ++++++++++++++++++++++++++++++--- tests/headless_inspect_test.rs | 219 ++++++++++++++++++++ 5 files changed, 609 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1fc1d9..a37ad86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -428,7 +437,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "excel-cli" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "calamine", @@ -438,6 +447,7 @@ dependencies = [ "indexmap", "quick-xml", "ratatui", + "regex", "rust_xlsxwriter", "serde", "serde_json", @@ -826,6 +836,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rust_xlsxwriter" version = "0.86.0" diff --git a/Cargo.toml b/Cargo.toml index b9d276e..3e7b7a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ indexmap = { version = "2.0", features = ["serde"] } tui-textarea = "0.4.0" quick-xml = "0.37.5" zip = "2.5.0" +regex = "1" [profile.release] opt-level = 3 diff --git a/src/cli/args.rs b/src/cli/args.rs index ac24ce9..447d1ae 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -191,6 +191,26 @@ pub enum ReadCommands { #[arg(long, default_value = "auto")] header_row: String, + /// Select columns by stable column name, comma-separated + #[arg(long)] + select: Option, + + /// Filter rows using field:op:value; repeat for AND semantics + #[arg(long = "filter")] + filters: Vec, + + /// Maximum number of rows to return + #[arg(long)] + limit: Option, + + /// Number of rows to skip after filtering + #[arg(long)] + offset: Option, + + /// Drop rows where every cell in the row is empty + #[arg(long)] + non_empty: bool, + /// Output format #[arg(long, value_enum, default_value = "json")] format: OutputFormat, diff --git a/src/cli/read.rs b/src/cli/read.rs index 088c1d6..065573d 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -1,5 +1,6 @@ use anyhow::Context; use quick_xml::events::Event; +use regex::Regex; use serde_json::{json, Value}; use std::collections::HashMap; use std::fs::File; @@ -35,8 +36,24 @@ pub fn handle(cmd: ReadCommands) -> Result { sheet_index, range, header_row, + select, + filters, + limit, + offset, + non_empty, format: _, - } => read_rows(file, sheet, sheet_index, range, header_row), + } => read_rows( + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + ), } } @@ -142,6 +159,214 @@ fn stable_record_keys(headers: &[String], start_col: usize) -> Vec { .collect() } +#[derive(Clone, Copy)] +enum FilterOp { + Eq, + Ne, + Gt, + Gte, + Lt, + Lte, + Contains, + Regex, + IsNull, + NotNull, +} + +struct FilterSpec { + raw: String, + col_idx: usize, + op: FilterOp, + value: String, + numeric_value: Option, + regex: Option, +} + +fn invalid_query(message: impl Into) -> AppError { + AppError::InvalidQuery { + message: message.into(), + } +} + +fn parse_selected_columns( + select: Option, + columns: &[String], +) -> Result, AppError> { + let Some(select) = select else { + return Ok((0..columns.len()).collect()); + }; + + let mut selected = Vec::new(); + for field in select.split(',').map(str::trim) { + if field.is_empty() { + return Err(invalid_query("Selected column names cannot be empty")); + } + let col_idx = columns + .iter() + .position(|column| column == field) + .ok_or_else(|| invalid_query(format!("Unknown selected column: {field}")))?; + selected.push(col_idx); + } + + Ok(selected) +} + +fn parse_filters(filters: Vec, columns: &[String]) -> Result, AppError> { + filters + .into_iter() + .map(|raw| { + let mut parts = raw.splitn(3, ':'); + let field = parts.next().unwrap_or_default().trim(); + let op = parts.next().unwrap_or_default().trim(); + let value = parts.next().ok_or_else(|| { + invalid_query(format!("Invalid filter '{raw}'; expected field:op:value")) + })?; + let value = value.to_string(); + + if field.is_empty() { + return Err(invalid_query(format!( + "Invalid filter '{raw}'; field is empty" + ))); + } + + let col_idx = columns + .iter() + .position(|column| column == field) + .ok_or_else(|| invalid_query(format!("Unknown filter column: {field}")))?; + + let op = match op { + "eq" => FilterOp::Eq, + "ne" => FilterOp::Ne, + "gt" => FilterOp::Gt, + "gte" => FilterOp::Gte, + "lt" => FilterOp::Lt, + "lte" => FilterOp::Lte, + "contains" => FilterOp::Contains, + "regex" => FilterOp::Regex, + "isnull" => FilterOp::IsNull, + "notnull" => FilterOp::NotNull, + "" => { + return Err(invalid_query(format!( + "Invalid filter '{raw}'; operator is empty" + ))) + } + _ => return Err(invalid_query(format!("Unknown filter operator: {op}"))), + }; + + let numeric_value = if matches!( + op, + FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte + ) { + Some(value.trim().parse::().map_err(|_| { + invalid_query(format!("Numeric filter value is invalid in '{raw}'")) + })?) + } else { + None + }; + + let regex = if matches!(op, FilterOp::Regex) { + Some( + Regex::new(&value) + .map_err(|err| invalid_query(format!("Invalid regex filter: {err}")))?, + ) + } else { + None + }; + + Ok(FilterSpec { + raw, + col_idx, + op, + value, + numeric_value, + regex, + }) + }) + .collect() +} + +fn value_as_filter_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + other => other.to_string(), + } +} + +fn value_as_number(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64(), + Value::String(value) => value.trim().parse::().ok(), + _ => None, + } +} + +fn is_empty_cell(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(value) => value.trim().is_empty(), + _ => false, + } +} + +fn compare_numeric(cell: &Value, filter_value: f64, compare: F) -> bool +where + F: Fn(f64, f64) -> bool, +{ + let Some(left) = value_as_number(cell) else { + return false; + }; + compare(left, filter_value) +} + +fn filter_matches(row: &[Value], filter: &FilterSpec) -> bool { + let Some(cell) = row.get(filter.col_idx) else { + return false; + }; + + match filter.op { + FilterOp::Eq => { + if let (Some(left), Ok(right)) = + (value_as_number(cell), filter.value.trim().parse::()) + { + (left - right).abs() < f64::EPSILON + } else { + value_as_filter_text(cell) == filter.value + } + } + FilterOp::Ne => { + if let (Some(left), Ok(right)) = + (value_as_number(cell), filter.value.trim().parse::()) + { + (left - right).abs() >= f64::EPSILON + } else { + value_as_filter_text(cell) != filter.value + } + } + FilterOp::Gt => { + compare_numeric(cell, filter.numeric_value.unwrap_or_default(), |a, b| a > b) + } + FilterOp::Gte => compare_numeric(cell, filter.numeric_value.unwrap_or_default(), |a, b| { + a >= b + }), + FilterOp::Lt => { + compare_numeric(cell, filter.numeric_value.unwrap_or_default(), |a, b| a < b) + } + FilterOp::Lte => compare_numeric(cell, filter.numeric_value.unwrap_or_default(), |a, b| { + a <= b + }), + FilterOp::Contains => value_as_filter_text(cell).contains(&filter.value), + FilterOp::Regex => filter + .regex + .as_ref() + .is_some_and(|regex| regex.is_match(&value_as_filter_text(cell))), + FilterOp::IsNull => is_empty_cell(cell), + FilterOp::NotNull => !is_empty_cell(cell), + } +} + fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { let mut entry = archive.by_name(entry_name).ok()?; let mut contents = String::new(); @@ -371,6 +596,11 @@ fn read_rows( sheet_index: Option, range: Option, header_row: String, + select: Option, + filters: Vec, + limit: Option, + offset: Option, + non_empty: bool, ) -> Result { let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); @@ -439,80 +669,146 @@ fn read_rows( end_row ); - let data = if let Some(header_row_idx) = resolved_header { - // Build records with headers + let (mode, columns, row_values) = if let Some(header_row_idx) = resolved_header { let mut headers = Vec::new(); - if header_row_idx < sheet_obj.data.len() { - for col in start_col..=end_col { - let val = if col < sheet_obj.data[header_row_idx].len() { - sheet_obj.data[header_row_idx][col].value.clone() - } else { - String::new() - }; - headers.push(val); - } + for col in start_col..=end_col { + let val = if header_row_idx < sheet_obj.data.len() + && col < sheet_obj.data[header_row_idx].len() + { + sheet_obj.data[header_row_idx][col].value.clone() + } else { + String::new() + }; + headers.push(val); } - let record_keys = stable_record_keys(&headers, start_col); + let columns = stable_record_keys(&headers, start_col); - let mut records = Vec::new(); + let mut row_values = Vec::new(); let data_start_row = start_row.max(header_row_idx.saturating_add(1)); for row in data_start_row..=end_row { if row >= sheet_obj.data.len() { break; } - let mut record = serde_json::Map::new(); - for (col_idx, col) in (start_col..=end_col).enumerate() { - let key = record_keys - .get(col_idx) - .cloned() - .unwrap_or_else(|| format!("col_{}", index_to_col_name(col))); + let mut values = Vec::new(); + for col in start_col..=end_col { let value = if col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { Value::Null }; - record.insert(key, value); + values.push(value); } - records.push(Value::Object(record)); + row_values.push(values); } - json!({ - "resolved_header_row": header_row_idx, - "mode": "records", - "records": records, - }) + ("records", columns, row_values) } else { - // Raw rows + let columns: Vec = (start_col..=end_col) + .map(|col| format!("col_{}", index_to_col_name(col))) + .collect(); let mut row_values = Vec::new(); for row in start_row..=end_row { if row >= sheet_obj.data.len() { break; } - let mut cols = Vec::new(); + let mut values = Vec::new(); for col in start_col..=end_col { let value = if col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { Value::Null }; - cols.push(value); + values.push(value); } - row_values.push(Value::Array(cols)); + row_values.push(values); } + ("rows", columns, row_values) + }; + + let selected_indices = parse_selected_columns(select, &columns)?; + let parsed_filters = parse_filters(filters, &columns)?; + let applied_filters: Vec = parsed_filters + .iter() + .map(|filter| filter.raw.clone()) + .collect(); + let selected_columns: Vec = selected_indices + .iter() + .map(|idx| columns[*idx].clone()) + .collect(); + + let mut filtered_rows: Vec> = row_values; + if non_empty { + filtered_rows.retain(|row| row.iter().any(|cell| !is_empty_cell(cell))); + } + filtered_rows.retain(|row| { + parsed_filters + .iter() + .all(|filter| filter_matches(row, filter)) + }); + + let offset = offset.unwrap_or(0); + let rows_after_offset: Vec> = filtered_rows.into_iter().skip(offset).collect(); + let truncated = limit.is_some_and(|size| rows_after_offset.len() > size); + let returned_rows: Vec> = if let Some(size) = limit { + rows_after_offset.into_iter().take(size).collect() + } else { + rows_after_offset + }; + + let row_count = returned_rows.len(); + + let data = if mode == "records" { + let records: Vec = returned_rows + .into_iter() + .map(|row| { + let mut record = serde_json::Map::new(); + for idx in &selected_indices { + let value = row.get(*idx).cloned().unwrap_or(Value::Null); + record.insert(columns[*idx].clone(), value); + } + Value::Object(record) + }) + .collect(); + + json!({ + "resolved_header_row": resolved_header.unwrap(), + "mode": "records", + "records": records, + }) + } else { + let rows: Vec = returned_rows + .into_iter() + .map(|row| { + Value::Array( + selected_indices + .iter() + .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) + .collect(), + ) + }) + .collect(); + json!({ "resolved_header_row": Value::Null, "mode": "rows", - "rows": row_values, + "rows": rows, }) }; + let meta = json!({ + "applied_filters": applied_filters, + "selected_columns": selected_columns, + "row_count": row_count, + "truncated": truncated, + }); + Ok(envelope::success_envelope( "read.rows", &path_str, &format_str, envelope::target_range(&sheet_name, index, &range_str), - json!({}), + meta, data, vec![], )) diff --git a/tests/headless_inspect_test.rs b/tests/headless_inspect_test.rs index 9b9a21d..e0dcf55 100644 --- a/tests/headless_inspect_test.rs +++ b/tests/headless_inspect_test.rs @@ -83,6 +83,43 @@ fn create_read_contract_workbook(path: &std::path::Path) { workbook.save(path).unwrap(); } +fn create_read_rows_extensions_workbook(path: &std::path::Path) { + use rust_xlsxwriter::Workbook as XlsxWorkbook; + + let mut workbook = XlsxWorkbook::new(); + + let sheet = workbook.add_worksheet(); + sheet.set_name("FilterRows").unwrap(); + sheet.write_string(0, 0, "name").unwrap(); + sheet.write_string(0, 1, "amount").unwrap(); + sheet.write_string(0, 2, "status").unwrap(); + sheet.write_string(0, 3, "note").unwrap(); + sheet.write_string(0, 4, "optional").unwrap(); + + sheet.write_string(1, 0, "Alice").unwrap(); + sheet.write_number(1, 1, 10.0).unwrap(); + sheet.write_string(1, 2, "open").unwrap(); + sheet.write_string(1, 3, "alpha").unwrap(); + + sheet.write_string(2, 0, "Bob").unwrap(); + sheet.write_number(2, 1, 25.0).unwrap(); + sheet.write_string(2, 2, "closed").unwrap(); + sheet.write_string(2, 3, "beta").unwrap(); + sheet.write_string(2, 4, "x").unwrap(); + + sheet.write_string(3, 0, "Carol").unwrap(); + sheet.write_number(3, 1, 40.0).unwrap(); + sheet.write_string(3, 2, "open").unwrap(); + sheet.write_string(3, 3, "carol:tag").unwrap(); + + sheet.write_string(5, 0, "Delta").unwrap(); + sheet.write_number(5, 1, 55.0).unwrap(); + sheet.write_string(5, 2, "open").unwrap(); + sheet.write_string(5, 3, "delta-special").unwrap(); + + workbook.save(path).unwrap(); +} + fn create_columns_contract_workbook(path: &std::path::Path) { use rust_xlsxwriter::Workbook as XlsxWorkbook; @@ -763,6 +800,188 @@ fn test_read_rows_explicit_header_row_respects_selected_range() { assert!(records[0].get("Quarterly export").is_none()); } +#[test] +fn test_read_rows_select_filters_pagination_and_meta() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_rows_extensions.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:E6") + .arg("--select") + .arg("name,amount") + .arg("--filter") + .arg("status:eq:open") + .arg("--filter") + .arg("amount:gte:40") + .arg("--limit") + .arg("1") + .arg("--offset") + .arg("0") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&output); + assert_eq!(json["data"]["mode"], "records"); + assert_eq!( + json["meta"]["applied_filters"], + serde_json::json!(["status:eq:open", "amount:gte:40"]) + ); + assert_eq!( + json["meta"]["selected_columns"], + serde_json::json!(["name", "amount"]) + ); + assert_eq!(json["meta"]["row_count"], 1); + assert_eq!(json["meta"]["truncated"], true); + + let records = json["data"]["records"].as_array().unwrap(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0], + serde_json::json!({"name": "Carol", "amount": 40}) + ); +} + +#[test] +fn test_read_rows_filter_operators_and_non_empty() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_rows_filter_ops.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let cases = [ + ("name:ne:Alice", vec!["Bob", "Carol", "Delta"]), + ("amount:gt:25", vec!["Carol", "Delta"]), + ("amount:lt:40", vec!["Alice", "Bob"]), + ("amount:lte:25", vec!["Alice", "Bob"]), + ("note:contains:special", vec!["Delta"]), + ("name:regex:^(Alice|Carol)$", vec!["Alice", "Carol"]), + ("optional:isnull:", vec!["Alice", "Carol", "Delta"]), + ("optional:notnull:", vec!["Bob"]), + ]; + + for (filter, expected_names) in cases { + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:E6") + .arg("--select") + .arg("name") + .arg("--filter") + .arg(filter) + .arg("--non-empty") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&output); + let actual_names: Vec<&str> = json["data"]["records"] + .as_array() + .unwrap() + .iter() + .map(|record| record["name"].as_str().unwrap()) + .collect(); + assert_eq!(actual_names, expected_names, "filter {filter}"); + assert_eq!(json["meta"]["row_count"], expected_names.len()); + assert_eq!(json["meta"]["truncated"], false); + } +} + +#[test] +fn test_read_rows_no_match_and_raw_column_selection() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_rows_raw_select.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let no_match = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:E6") + .arg("--filter") + .arg("name:eq:Missing") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&no_match); + assert!(json["data"]["records"].as_array().unwrap().is_empty()); + assert_eq!(json["meta"]["row_count"], 0); + assert_eq!(json["meta"]["truncated"], false); + + let raw = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A2:E6") + .arg("--header-row") + .arg("999") + .arg("--select") + .arg("col_A,col_C") + .arg("--filter") + .arg("col_B:gte:40") + .arg("--non-empty") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&raw); + assert_eq!(json["data"]["mode"], "rows"); + assert_eq!( + json["meta"]["selected_columns"], + serde_json::json!(["col_A", "col_C"]) + ); + assert_eq!(json["meta"]["row_count"], 2); + assert_eq!( + json["data"]["rows"], + serde_json::json!([["Carol", "open"], ["Delta", "open"]]) + ); +} + +#[test] +fn test_read_rows_invalid_select_and_filters_are_structured_errors() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_rows_invalid_filters.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let cases = [ + vec!["--select", "missing"], + vec!["--filter", "missing:eq:value"], + vec!["--filter", "name:starts:value"], + vec!["--filter", "name:eq"], + vec!["--filter", "name:regex:["], + vec!["--filter", "amount:gt:not-a-number"], + ]; + + for args in cases { + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:E6") + .args(args) + .output() + .expect("Failed to execute excel-cli"); + + assert_json_error(&output, 6); + } +} + #[test] fn test_inspect_sample_json() { let temp_dir = std::env::temp_dir(); From f45048303a761e99f84f9c0644300df5809e8d4f Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Tue, 21 Apr 2026 22:38:12 +0800 Subject: [PATCH 2/5] feat: add read records output shapes --- src/cli/args.rs | 71 ++++++++ src/cli/dispatch.rs | 1 + src/cli/output.rs | 21 +++ src/cli/read.rs | 122 ++++++++++---- tests/headless_inspect_test.rs | 289 ++++++++++++++++++++++++++++++++- 5 files changed, 471 insertions(+), 33 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 447d1ae..2306b11 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -211,6 +211,59 @@ pub enum ReadCommands { #[arg(long)] non_empty: bool, + /// Output shape for row data + #[arg(long, value_enum, default_value = "rows")] + output_shape: OutputShape, + + /// Output format + #[arg(long, value_enum, default_value = "json")] + format: OutputFormat, + }, + /// Read records from a sheet using a resolved header row + Records { + /// Excel file path + file: PathBuf, + + /// Sheet name (exact match) + #[arg(long, group = "sheet_target")] + sheet: Option, + + /// Sheet index (0-based) + #[arg(long, group = "sheet_target")] + sheet_index: Option, + + /// Range to read (A1 notation) + #[arg(long)] + range: Option, + + /// Header row: auto or 1-based index + #[arg(long, default_value = "auto")] + header_row: String, + + /// Select columns by stable column name, comma-separated + #[arg(long)] + select: Option, + + /// Filter rows using field:op:value; repeat for AND semantics + #[arg(long = "filter")] + filters: Vec, + + /// Maximum number of rows to return + #[arg(long)] + limit: Option, + + /// Number of rows to skip after filtering + #[arg(long)] + offset: Option, + + /// Drop rows where every cell in the row is empty + #[arg(long)] + non_empty: bool, + + /// Output shape for row data + #[arg(long, value_enum, default_value = "records")] + output_shape: OutputShape, + /// Output format #[arg(long, value_enum, default_value = "json")] format: OutputFormat, @@ -233,6 +286,24 @@ impl OutputFormat { } } +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum, PartialEq, Eq)] +pub enum OutputShape { + #[default] + Rows, + Records, + Jsonl, +} + +impl OutputShape { + pub fn as_str(&self) -> &str { + match self { + OutputShape::Rows => "rows", + OutputShape::Records => "records", + OutputShape::Jsonl => "jsonl", + } + } +} + /// Resolve the sheet target (by name or index) to a sheet index. pub fn resolve_sheet_target( workbook: &crate::excel::Workbook, diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 9ee3536..7681345 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -21,6 +21,7 @@ pub fn dispatch(cli: Cli) -> Result<(Value, crate::cli::args::OutputFormat), App crate::cli::args::ReadCommands::Cell { format, .. } => format.clone(), crate::cli::args::ReadCommands::Range { format, .. } => format.clone(), crate::cli::args::ReadCommands::Rows { format, .. } => format.clone(), + crate::cli::args::ReadCommands::Records { format, .. } => format.clone(), }; let value = crate::cli::read::handle(subcommand)?; Ok((value, format)) diff --git a/src/cli/output.rs b/src/cli/output.rs index b8eccc3..6a37417 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -5,6 +5,10 @@ use crate::cli::error::AppError; /// Write a success value to stdout. pub fn write_success(value: &Value, format: &OutputFormat) -> Result<(), AppError> { + if value["meta"]["output_shape"].as_str() == Some("jsonl") { + return write_jsonl_records(value); + } + match format { OutputFormat::Json => { let s = serde_json::to_string_pretty(value).map_err(|e| AppError::InternalError { @@ -19,6 +23,23 @@ pub fn write_success(value: &Value, format: &OutputFormat) -> Result<(), AppErro Ok(()) } +fn write_jsonl_records(value: &Value) -> Result<(), AppError> { + let Some(records) = value["data"]["records"].as_array() else { + return Err(AppError::InternalError { + message: "JSONL output requires record data".to_string(), + }); + }; + + for record in records { + let s = serde_json::to_string(record).map_err(|e| AppError::InternalError { + message: format!("JSON serialization failed: {}", e), + })?; + println!("{}", s); + } + + Ok(()) +} + /// Write an error value to stderr. pub fn write_error(value: &Value) { if let Ok(s) = serde_json::to_string_pretty(value) { diff --git a/src/cli/read.rs b/src/cli/read.rs index 065573d..0602dae 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -8,7 +8,7 @@ use std::io::{Read, Seek}; use std::path::Path; use zip::ZipArchive; -use crate::cli::args::{resolve_sheet_target, ReadCommands}; +use crate::cli::args::{resolve_sheet_target, OutputFormat, OutputShape, ReadCommands}; use crate::cli::envelope; use crate::cli::error::AppError; use crate::excel::{open_workbook, CellType}; @@ -41,8 +41,40 @@ pub fn handle(cmd: ReadCommands) -> Result { limit, offset, non_empty, - format: _, + output_shape, + format, + } => read_rows( + "read.rows", + false, + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + output_shape, + format, + ), + ReadCommands::Records { + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + output_shape, + format, } => read_rows( + "read.records", + true, file, sheet, sheet_index, @@ -53,6 +85,8 @@ pub fn handle(cmd: ReadCommands) -> Result { limit, offset, non_empty, + output_shape, + format, ), } } @@ -591,6 +625,8 @@ fn read_range( } fn read_rows( + command: &'static str, + command_requires_header: bool, file: std::path::PathBuf, sheet: Option, sheet_index: Option, @@ -601,7 +637,15 @@ fn read_rows( limit: Option, offset: Option, non_empty: bool, + output_shape: OutputShape, + format: OutputFormat, ) -> Result { + if output_shape == OutputShape::Jsonl && matches!(format, OutputFormat::Text) { + return Err(AppError::InvalidArgs { + message: "--output-shape jsonl cannot be combined with --format text".to_string(), + }); + } + let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); @@ -669,7 +713,16 @@ fn read_rows( end_row ); - let (mode, columns, row_values) = if let Some(header_row_idx) = resolved_header { + if resolved_header.is_none() + && (command_requires_header + || matches!(output_shape, OutputShape::Records | OutputShape::Jsonl)) + { + return Err(invalid_query( + "A resolved header row is required for records or jsonl output", + )); + } + + let (has_header, columns, row_values) = if let Some(header_row_idx) = resolved_header { let mut headers = Vec::new(); for col in start_col..=end_col { let val = if header_row_idx < sheet_obj.data.len() @@ -701,7 +754,7 @@ fn read_rows( row_values.push(values); } - ("records", columns, row_values) + (true, columns, row_values) } else { let columns: Vec = (start_col..=end_col) .map(|col| format!("col_{}", index_to_col_name(col))) @@ -723,7 +776,7 @@ fn read_rows( row_values.push(values); } - ("rows", columns, row_values) + (false, columns, row_values) }; let selected_indices = parse_selected_columns(select, &columns)?; @@ -758,39 +811,43 @@ fn read_rows( let row_count = returned_rows.len(); - let data = if mode == "records" { - let records: Vec = returned_rows - .into_iter() - .map(|row| { - let mut record = serde_json::Map::new(); - for idx in &selected_indices { - let value = row.get(*idx).cloned().unwrap_or(Value::Null); - record.insert(columns[*idx].clone(), value); - } - Value::Object(record) - }) - .collect(); + let records: Vec = returned_rows + .iter() + .map(|row| { + let mut record = serde_json::Map::new(); + for idx in &selected_indices { + let value = row.get(*idx).cloned().unwrap_or(Value::Null); + record.insert(columns[*idx].clone(), value); + } + Value::Object(record) + }) + .collect(); + + let rows: Vec = returned_rows + .into_iter() + .map(|row| { + Value::Array( + selected_indices + .iter() + .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) + .collect(), + ) + }) + .collect(); + let data = if matches!(output_shape, OutputShape::Records | OutputShape::Jsonl) { json!({ "resolved_header_row": resolved_header.unwrap(), - "mode": "records", + "mode": output_shape.as_str(), "records": records, }) } else { - let rows: Vec = returned_rows - .into_iter() - .map(|row| { - Value::Array( - selected_indices - .iter() - .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) - .collect(), - ) - }) - .collect(); - json!({ - "resolved_header_row": Value::Null, + "resolved_header_row": if has_header { + resolved_header.map(Value::from).unwrap_or(Value::Null) + } else { + Value::Null + }, "mode": "rows", "rows": rows, }) @@ -801,10 +858,11 @@ fn read_rows( "selected_columns": selected_columns, "row_count": row_count, "truncated": truncated, + "output_shape": output_shape.as_str(), }); Ok(envelope::success_envelope( - "read.rows", + command, &path_str, &format_str, envelope::target_range(&sheet_name, index, &range_str), diff --git a/tests/headless_inspect_test.rs b/tests/headless_inspect_test.rs index e0dcf55..1e24847 100644 --- a/tests/headless_inspect_test.rs +++ b/tests/headless_inspect_test.rs @@ -272,6 +272,34 @@ fn assert_json_error(output: &std::process::Output, expected_exit_code: i32) { ); } +fn assert_jsonl_success(output: &std::process::Output) -> Vec { + assert!( + output.status.success(), + "Expected success. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "Expected empty stderr on success. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.trim_start().starts_with('['), + "JSONL output must not be an array: {stdout}" + ); + assert!( + !stdout.trim_start().starts_with("{\n \"schema_version\""), + "JSONL output must not be an envelope: {stdout}" + ); + + stdout + .lines() + .map(|line| serde_json::from_str(line).expect("Expected valid JSONL record")) + .collect() +} + #[test] fn test_inspect_workbook_json() { let temp_dir = std::env::temp_dir(); @@ -683,12 +711,14 @@ fn test_read_rows_json() { .arg(&file_path) .arg("--sheet") .arg("Orders") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); let json = assert_json_success(&output); assert_eq!(json["command"], "read.rows"); - // Should return records mode because header row 1 is detected + // Explicit records shape should use detected header row 1. assert_eq!(json["data"]["mode"], "records"); let records = json["data"]["records"].as_array().unwrap(); assert_eq!(records.len(), 1); @@ -710,6 +740,8 @@ fn test_read_rows_with_header_row() { .arg("Orders") .arg("--header-row") .arg("1") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); @@ -758,6 +790,8 @@ fn test_read_rows_auto_skips_preamble_and_keeps_unique_keys() { .arg("RowCases") .arg("--range") .arg("A1:D4") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); @@ -789,6 +823,8 @@ fn test_read_rows_explicit_header_row_respects_selected_range() { .arg("A1:D4") .arg("--header-row") .arg("2") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); @@ -824,6 +860,8 @@ fn test_read_rows_select_filters_pagination_and_meta() { .arg("1") .arg("--offset") .arg("0") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); @@ -848,6 +886,251 @@ fn test_read_rows_select_filters_pagination_and_meta() { ); } +#[test] +fn test_read_rows_output_shape_rows_returns_positional_rows() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_rows_output_shape_rows.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:C4") + .arg("--select") + .arg("name,status") + .arg("--filter") + .arg("status:eq:open") + .arg("--output-shape") + .arg("rows") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&output); + assert_eq!(json["command"], "read.rows"); + assert_eq!(json["meta"]["output_shape"], "rows"); + assert_eq!(json["data"]["mode"], "rows"); + assert!(json["data"].get("records").is_none()); + assert_eq!( + json["data"]["rows"], + serde_json::json!([["Alice", "open"], ["Carol", "open"]]) + ); +} + +#[test] +fn test_read_records_defaults_to_records_shape() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_records_default.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("records") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:C4") + .arg("--select") + .arg("name,amount") + .arg("--filter") + .arg("status:eq:open") + .output() + .expect("Failed to execute excel-cli"); + + let json = assert_json_success(&output); + assert_eq!(json["command"], "read.records"); + assert_eq!(json["meta"]["output_shape"], "records"); + assert_eq!(json["data"]["mode"], "records"); + assert!(json["data"].get("rows").is_none()); + assert_eq!( + json["data"]["records"], + serde_json::json!([ + {"name": "Alice", "amount": 10}, + {"name": "Carol", "amount": 40} + ]) + ); +} + +#[test] +fn test_output_shape_jsonl_writes_newline_delimited_records() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_records_jsonl.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("records") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A1:E6") + .arg("--select") + .arg("name,amount") + .arg("--filter") + .arg("status:eq:open") + .arg("--limit") + .arg("2") + .arg("--output-shape") + .arg("jsonl") + .output() + .expect("Failed to execute excel-cli"); + + let records = assert_jsonl_success(&output); + assert_eq!( + records, + vec![ + serde_json::json!({"name": "Alice", "amount": 10}), + serde_json::json!({"name": "Carol", "amount": 40}), + ] + ); +} + +#[test] +fn test_output_shape_does_not_change_selection_filter_or_pagination() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_output_shape_invariance.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let mut base_args = vec![ + "read", + "rows", + file_path.to_str().unwrap(), + "--sheet", + "FilterRows", + "--range", + "A1:E6", + "--select", + "name,amount", + "--filter", + "status:eq:open", + "--offset", + "1", + "--limit", + "2", + ]; + + let rows_output = Command::new(excel_cli_bin()) + .args(&base_args) + .arg("--output-shape") + .arg("rows") + .output() + .expect("Failed to execute excel-cli"); + let rows_json = assert_json_success(&rows_output); + + base_args[1] = "records"; + let records_output = Command::new(excel_cli_bin()) + .args(&base_args) + .arg("--output-shape") + .arg("records") + .output() + .expect("Failed to execute excel-cli"); + let records_json = assert_json_success(&records_output); + + let jsonl_output = Command::new(excel_cli_bin()) + .args(&base_args) + .arg("--output-shape") + .arg("jsonl") + .output() + .expect("Failed to execute excel-cli"); + let jsonl_records = assert_jsonl_success(&jsonl_output); + + assert_eq!(rows_json["meta"]["row_count"], 2); + assert_eq!(rows_json["meta"]["truncated"], false); + assert_eq!( + rows_json["meta"]["selected_columns"], + serde_json::json!(["name", "amount"]) + ); + assert_eq!( + records_json["meta"]["row_count"], + rows_json["meta"]["row_count"] + ); + assert_eq!( + records_json["meta"]["selected_columns"], + rows_json["meta"]["selected_columns"] + ); + assert_eq!( + rows_json["data"]["rows"], + serde_json::json!([["Carol", 40], ["Delta", 55]]) + ); + assert_eq!( + records_json["data"]["records"], + serde_json::json!([ + {"name": "Carol", "amount": 40}, + {"name": "Delta", "amount": 55} + ]) + ); + assert_eq!( + serde_json::Value::Array(jsonl_records), + records_json["data"]["records"] + ); +} + +#[test] +fn test_read_records_requires_resolved_header() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_records_header_error.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("records") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A2:E6") + .arg("--header-row") + .arg("999") + .output() + .expect("Failed to execute excel-cli"); + + assert_json_error(&output, 6); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("rows") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--range") + .arg("A2:E6") + .arg("--header-row") + .arg("999") + .arg("--output-shape") + .arg("jsonl") + .output() + .expect("Failed to execute excel-cli"); + + assert_json_error(&output, 6); +} + +#[test] +fn test_output_shape_jsonl_rejects_text_format() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_records_jsonl_text.xlsx"); + create_read_rows_extensions_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("records") + .arg(&file_path) + .arg("--sheet") + .arg("FilterRows") + .arg("--output-shape") + .arg("jsonl") + .arg("--format") + .arg("text") + .output() + .expect("Failed to execute excel-cli"); + + assert_json_error(&output, 2); +} + #[test] fn test_read_rows_filter_operators_and_non_empty() { let temp_dir = std::env::temp_dir(); @@ -879,6 +1162,8 @@ fn test_read_rows_filter_operators_and_non_empty() { .arg("--filter") .arg(filter) .arg("--non-empty") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); @@ -911,6 +1196,8 @@ fn test_read_rows_no_match_and_raw_column_selection() { .arg("A1:E6") .arg("--filter") .arg("name:eq:Missing") + .arg("--output-shape") + .arg("records") .output() .expect("Failed to execute excel-cli"); From 3830241d49a4882b6db6bdb66e3ed4ce62265ba6 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Tue, 21 Apr 2026 22:33:48 +0800 Subject: [PATCH 3/5] feat: add TUI query preview --- src/app/mod.rs | 2 + src/app/query_preview.rs | 195 +++++++++++++++++++++++++++++++++++++++ src/app/state.rs | 9 ++ src/app/ui.rs | 1 + src/commands/executor.rs | 54 +++++++++++ src/ui/handlers.rs | 62 +++++++++++++ src/ui/render.rs | 122 +++++++++++++++++++++++- 7 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 src/app/query_preview.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 3f67c11..97de0b2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ mod edit; mod navigation; +mod query_preview; mod search; mod sheet; mod state; @@ -8,5 +9,6 @@ mod undo_manager; mod vim; mod word; +pub use query_preview::*; pub use state::*; pub use vim::*; diff --git a/src/app/query_preview.rs b/src/app/query_preview.rs new file mode 100644 index 0000000..692691e --- /dev/null +++ b/src/app/query_preview.rs @@ -0,0 +1,195 @@ +use crate::app::{AppState, InputMode}; +use crate::utils::{cell_reference, index_to_col_name}; + +const SAMPLE_ROWS: usize = 6; +const SAMPLE_COLS: usize = 6; +const MAX_CELL_DISPLAY_CHARS: usize = 24; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryPreview { + pub file_path: String, + pub sheet_name: String, + pub sheet_index: usize, + pub selected_cell: String, + pub used_range: String, + pub selects: String, + pub filters: String, + pub columns: Vec, + pub rows: Vec, + pub truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryPreviewRow { + pub row_number: usize, + pub values: Vec, +} + +impl QueryPreview { + fn from_app(app: &AppState) -> Self { + let sheet = app.workbook.get_current_sheet(); + let sheet_index = app.workbook.get_current_sheet_index(); + let selected_cell = cell_reference(app.selected_cell); + let used_range = if sheet.max_rows == 0 || sheet.max_cols == 0 { + "empty".to_string() + } else { + format!("A1:{}{}", index_to_col_name(sheet.max_cols), sheet.max_rows) + }; + + let sample_start_row = if sheet.max_rows == 0 { + 0 + } else { + app.selected_cell.0.clamp(1, sheet.max_rows) + }; + let sample_start_col = if sheet.max_cols == 0 { 0 } else { 1 }; + let sample_end_row = (sample_start_row + SAMPLE_ROWS.saturating_sub(1)).min(sheet.max_rows); + let sample_end_col = (sample_start_col + SAMPLE_COLS.saturating_sub(1)).min(sheet.max_cols); + + let columns = if sample_start_col == 0 { + Vec::new() + } else { + (sample_start_col..=sample_end_col) + .map(index_to_col_name) + .collect() + }; + + let rows = if sample_start_row == 0 || sample_start_col == 0 { + Vec::new() + } else { + (sample_start_row..=sample_end_row) + .map(|row| QueryPreviewRow { + row_number: row, + values: (sample_start_col..=sample_end_col) + .map(|col| { + sheet + .data + .get(row) + .and_then(|cells| cells.get(col)) + .map(|cell| truncate_cell(&cell.value)) + .unwrap_or_default() + }) + .collect(), + }) + .collect() + }; + + let truncated = sample_start_row > 1 + || sample_start_col > 1 + || sample_end_row < sheet.max_rows + || sample_end_col < sheet.max_cols; + + Self { + file_path: app.file_path.to_string_lossy().to_string(), + sheet_name: sheet.name.clone(), + sheet_index: sheet_index + 1, + selected_cell, + used_range, + selects: "all columns".to_string(), + filters: "none".to_string(), + columns, + rows, + truncated, + } + } +} + +impl AppState<'_> { + pub fn show_query_preview(&mut self) { + let sheet_index = self.workbook.get_current_sheet_index(); + let sheet_name = self.workbook.get_current_sheet_name(); + + if self.workbook.is_lazy_loading() && !self.workbook.is_sheet_loaded(sheet_index) { + if let Err(e) = self.workbook.ensure_sheet_loaded(sheet_index, &sheet_name) { + self.add_notification(format!("Preview failed: {e}")); + return; + } + } + + self.query_preview = Some(QueryPreview::from_app(self)); + self.input_mode = InputMode::Preview; + } + + pub fn close_query_preview(&mut self) { + self.query_preview = None; + self.input_mode = InputMode::Normal; + } +} + +fn truncate_cell(value: &str) -> String { + let mut result = String::new(); + for (idx, ch) in value.chars().enumerate() { + if idx >= MAX_CELL_DISPLAY_CHARS { + result.push_str("..."); + return result; + } + result.push(ch); + } + result +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::app::AppState; + use crate::excel::{Cell, Sheet, Workbook}; + + fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { + let max_rows = values.len(); + let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); + let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; + + for (row_idx, row) in values.iter().enumerate() { + for (col_idx, value) in row.iter().enumerate() { + data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); + } + } + + Sheet { + name: name.to_string(), + data, + max_rows, + max_cols, + is_loaded: true, + } + } + + #[test] + fn preview_snapshots_current_target_and_capped_sample() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Data", + &[ + &[ + "Name", "Region", "Sales", "Owner", "Quarter", "Status", "Notes", + ], + &["Ada", "West", "10", "Mina", "Q1", "Open", "A"], + &["Ben", "East", "12", "Noor", "Q1", "Won", "B"], + &["Cid", "North", "9", "Ira", "Q2", "Open", "C"], + &["Dee", "South", "7", "Ola", "Q2", "Lost", "D"], + &["Eli", "West", "8", "Paz", "Q3", "Open", "E"], + &["Fay", "East", "11", "Uma", "Q3", "Won", "F"], + ], + )]); + let mut app = AppState::new(workbook, PathBuf::from("/tmp/report.xlsx")).unwrap(); + app.selected_cell = (2, 2); + + app.show_query_preview(); + + let preview = app.query_preview.as_ref().expect("preview should be set"); + assert_eq!(preview.file_path, "/tmp/report.xlsx"); + assert_eq!(preview.sheet_name, "Data"); + assert_eq!(preview.sheet_index, 1); + assert_eq!(preview.selected_cell, "B2"); + assert_eq!(preview.used_range, "A1:G7"); + assert_eq!(preview.selects, "all columns"); + assert_eq!(preview.filters, "none"); + assert_eq!(preview.columns, vec!["A", "B", "C", "D", "E", "F"]); + assert_eq!(preview.rows.len(), 6); + assert_eq!(preview.rows[0].row_number, 2); + assert_eq!( + preview.rows[0].values, + vec!["Ada", "West", "10", "Mina", "Q1", "Open"] + ); + assert!(preview.truncated); + } +} diff --git a/src/app/state.rs b/src/app/state.rs index 1e5d248..484e2e1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use tui_textarea::TextArea; use crate::actions::UndoHistory; +use crate::app::QueryPreview; use crate::app::VimState; use crate::excel::Workbook; @@ -23,6 +24,7 @@ pub enum InputMode { SearchForward, SearchBackward, Help, + Preview, LazyLoading, CommandInLazyLoading, } @@ -56,6 +58,7 @@ pub struct AppState<'a> { pub help_text: String, pub help_scroll: usize, pub help_visible_lines: usize, + pub query_preview: Option, pub undo_history: UndoHistory, pub vim_state: Option, } @@ -153,6 +156,7 @@ impl AppState<'_> { help_text: String::new(), help_scroll: 0, help_visible_lines: 20, + query_preview: None, undo_history: UndoHistory::new(), vim_state: None, }) @@ -219,6 +223,11 @@ impl AppState<'_> { } pub fn cancel_input(&mut self) { + if let InputMode::Preview = self.input_mode { + self.close_query_preview(); + return; + } + // If in help mode, just close the help window if let InputMode::Help = self.input_mode { self.input_mode = InputMode::Normal; diff --git a/src/app/ui.rs b/src/app/ui.rs index ab83574..ea7c0b3 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -12,6 +12,7 @@ impl AppState<'_> { :q! - Force quit without saving\n\n\ NAVIGATION:\n\ :[cell] - Jump to cell (e.g., :B10)\n\ + :preview, :pv - Show read-only preview of current sheet data\n\ hjkl - Move cursor (left, down, up, right)\n\ 0 - Jump to first column\n\ ^ - Jump to first non-empty column\n\ diff --git a/src/commands/executor.rs b/src/commands/executor.rs index f49ae8d..617d9d2 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -52,6 +52,7 @@ impl AppState<'_> { } "nohlsearch" | "noh" => self.disable_search_highlight(), "help" => self.show_help(), + "preview" | "pv" => self.show_query_preview(), "delsheet" => self.delete_current_sheet(), "addsheet" => self.add_notification("Usage: :addsheet ".to_string()), _ => { @@ -393,7 +394,11 @@ fn parse_cell_reference(input: &str) -> Option<(usize, usize)> { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::parse_cell_reference; + use crate::app::{AppState, InputMode}; + use crate::excel::{Cell, Sheet, Workbook}; #[test] fn parses_valid_cell_references() { @@ -406,4 +411,53 @@ mod tests { assert_eq!(parse_cell_reference("addsheet 测试1"), None); assert_eq!(parse_cell_reference("测试1"), None); } + + fn app_with_sheet() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 3]; 3]; + data[1][1] = Cell::new("Name".to_string(), false); + data[1][2] = Cell::new("Score".to_string(), false); + data[2][1] = Cell::new("Ada".to_string(), false); + data[2][2] = Cell::new("10".to_string(), false); + + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 2, + max_cols: 2, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("scores.xlsx"), + ) + .unwrap() + } + + #[test] + fn preview_command_populates_preview_state() { + let mut app = app_with_sheet(); + app.input_buffer = "preview".to_string(); + + app.execute_command(); + + assert!(matches!(app.input_mode, InputMode::Preview)); + assert_eq!( + app.query_preview + .as_ref() + .map(|preview| preview.sheet_name.as_str()), + Some("Data") + ); + } + + #[test] + fn preview_alias_populates_preview_state() { + let mut app = app_with_sheet(); + app.input_buffer = "pv".to_string(); + + app.execute_command(); + + assert!(matches!(app.input_mode, InputMode::Preview)); + assert!(app.query_preview.is_some()); + } } diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index b0b871f..3fb6ac1 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -20,6 +20,7 @@ pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { InputMode::SearchForward => handle_search_mode(app_state, key.code), InputMode::SearchBackward => handle_search_mode(app_state, key.code), InputMode::Help => handle_help_mode(app_state, key.code), + InputMode::Preview => handle_preview_mode(app_state, key.code), InputMode::LazyLoading => handle_lazy_loading_mode(app_state, key.code), } } @@ -89,6 +90,13 @@ fn handle_command_in_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCo } } +fn handle_preview_mode(app_state: &mut AppState, key_code: KeyCode) { + match key_code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => app_state.close_query_preview(), + _ => {} + } +} + fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { KeyCode::Enter => { @@ -265,6 +273,60 @@ fn handle_editing_mode(app_state: &mut AppState, key: KeyEvent) { } } +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::path::PathBuf; + + use super::handle_key_event; + use crate::app::{AppState, InputMode}; + use crate::excel::{Cell, Sheet, Workbook}; + + fn app_with_preview() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 2]; 2]; + data[1][1] = Cell::new("Ada".to_string(), false); + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 1, + max_cols: 1, + is_loaded: true, + }; + let mut app = AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("test.xlsx"), + ) + .unwrap(); + app.show_query_preview(); + app + } + + #[test] + fn escape_closes_preview_without_quitting() { + let mut app = app_with_preview(); + + handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(app.query_preview.is_none()); + assert!(!app.should_quit); + } + + #[test] + fn q_closes_preview_without_quitting() { + let mut app = app_with_preview(); + + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), + ); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(app.query_preview.is_none()); + assert!(!app.should_quit); + } +} + fn handle_search_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { KeyCode::Enter => app_state.execute_search(), diff --git a/src/ui/render.rs b/src/ui/render.rs index 69d3066..c3f181c 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -322,6 +322,8 @@ fn parse_command(input: &str) -> Vec> { "nohlsearch", "noh", "help", + "preview", + "pv", "addsheet", "delsheet", ]; @@ -379,12 +381,14 @@ fn parse_command(input: &str) -> Vec> { } fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { + let constraints = if matches!(app_state.input_mode, InputMode::Preview) { + [Constraint::Percentage(75), Constraint::Percentage(25)] + } else { + [Constraint::Percentage(50), Constraint::Percentage(50)] + }; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(50), // Cell content/editing area - Constraint::Percentage(50), // Notifications - ]) + .constraints(constraints) .split(area); // Get the cell reference @@ -392,7 +396,22 @@ fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { let cell_ref = cell_reference(app_state.selected_cell); // Handle the top panel based on the input mode - if let InputMode::Editing = app_state.input_mode { + if let InputMode::Preview = app_state.input_mode { + let preview_text = app_state + .query_preview + .as_ref() + .map(format_query_preview) + .unwrap_or_else(|| "No preview available".to_string()); + let preview_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)) + .title(" Query Preview "); + let preview_paragraph = Paragraph::new(preview_text) + .block(preview_block) + .wrap(ratatui::widgets::Wrap { trim: false }); + + f.render_widget(preview_paragraph, chunks[0]); + } else if let InputMode::Editing = app_state.input_mode { let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { match vim_state.mode { crate::app::VimMode::Normal => ("NORMAL", Color::Green), @@ -495,6 +514,33 @@ fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { f.render_widget(notification_paragraph, chunks[1]); } +fn format_query_preview(preview: &crate::app::QueryPreview) -> String { + let mut lines = vec![ + format!("File: {}", preview.file_path), + format!( + "Sheet: {} ({}) | Selected: {} | Range: {}", + preview.sheet_name, preview.sheet_index, preview.selected_cell, preview.used_range + ), + format!("Select: {} | Filters: {}", preview.selects, preview.filters), + ]; + + if preview.rows.is_empty() { + lines.push("Sample: no rows".to_string()); + return lines.join("\n"); + } + + lines.push(format!("Sample: row | {}", preview.columns.join(" | "))); + for row in &preview.rows { + lines.push(format!("{} | {}", row.row_number, row.values.join(" | "))); + } + + if preview.truncated { + lines.push("Sample truncated".to_string()); + } + + lines.join("\n") +} + fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { match app_state.input_mode { InputMode::Normal => { @@ -565,6 +611,14 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { // No status bar in help mode } + InputMode::Preview => { + let status_widget = Paragraph::new("Query preview: Esc/Enter/q closes") + .style(Style::default().fg(Color::LightCyan)) + .alignment(ratatui::layout::Alignment::Left); + + f.render_widget(status_widget, area); + } + InputMode::LazyLoading => { // Show a status message for lazy loading mode let status_widget = Paragraph::new( @@ -808,3 +862,61 @@ fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { f.render_widget(indicator_widget, indicator_rect); } } + +#[cfg(test)] +mod tests { + use ratatui::{backend::TestBackend, Terminal}; + use std::path::PathBuf; + + use super::ui; + use crate::app::{AppState, InputMode}; + use crate::excel::{Cell, Sheet, Workbook}; + + fn app_with_preview() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 3]; 3]; + data[1][1] = Cell::new("Name".to_string(), false); + data[1][2] = Cell::new("Score".to_string(), false); + data[2][1] = Cell::new("Ada".to_string(), false); + data[2][2] = Cell::new("10".to_string(), false); + + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 2, + max_cols: 2, + is_loaded: true, + }; + let mut app = AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("scores.xlsx"), + ) + .unwrap(); + app.show_query_preview(); + app + } + + #[test] + fn renders_query_preview_pane_with_target_and_sample() { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_preview(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Preview)); + assert!(rendered.contains("Query Preview")); + assert!(rendered.contains("Sheet: Data")); + assert!(rendered.contains("Range: A1:B2")); + assert!(rendered.contains("Select: all columns")); + assert!(rendered.contains("Filters: none")); + assert!(rendered.contains("Ada")); + } +} From 6e0865f467014bf0573cda8524c0694ea0dbfd33 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Tue, 21 Apr 2026 23:00:51 +0800 Subject: [PATCH 4/5] chore: prepare v1.2.0 release readiness --- .github/workflows/build-and-test.yml | 4 ++ CHANGELOG.md | 24 ++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 57 ++++++++++++++++ src/cli/args.rs | 6 +- src/cli/read.rs | 97 +++++++++++++++++----------- tests/help_and_version_cli_test.rs | 85 +++++++++++++++++------- 8 files changed, 211 insertions(+), 66 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 2707203..c1debf5 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,7 +20,11 @@ jobs: run: | rustup update stable rustup default stable + rustup component add rustfmt rustup component add clippy + + - name: Check formatting + run: cargo fmt --check - name: Build run: cargo build --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index f91ffde..2c7813b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-04-21 + +### Added + +- `read rows` column selection with `--select `. +- `read rows` and `read records` filtering with repeated `--filter field:op:value` conditions combined with AND semantics. +- Filter operators for equality, inequality, numeric comparisons, string contains, regular expressions, null checks, and non-null checks. +- Pagination controls with `--limit` and `--offset`. +- `--non-empty` to drop all-empty rows from read results. +- `read records ` for header-keyed records by default. +- `--output-shape rows|records|jsonl` on row and record reads. +- JSON Lines output for stream-friendly record processing. +- Read-only TUI query preview with `:preview` and `:pv`. +- Regression coverage for filtering, output shapes, invalid query errors, no-match results, help text, and query preview behavior. +- CI formatting enforcement with `cargo fmt --check`. + +### Changed + +- Enriched read metadata now reports applied filters, selected columns, returned row count, truncation, and output shape. +- Package version updated to `1.2.0`. + ## [1.1.0] - 2026-04-21 ### Added @@ -174,7 +195,8 @@ This is the initial release of excel-cli, a lightweight terminal-based Excel vie - Copy, cut, and paste functionality with `y`, `d`, and `p` keys - Support for pipe operator when exporting to JSON -[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/fuhan666/excel-cli/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/fuhan666/excel-cli/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.0.0 [0.5.2]: https://github.com/fuhan666/excel-cli/releases/tag/v0.5.2 diff --git a/Cargo.lock b/Cargo.lock index a37ad86..56de56f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,7 +437,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "excel-cli" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 3e7b7a5..0a68187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "excel-cli" -version = "1.1.0" +version = "1.2.0" edition = "2021" description = "Excel CLI for AI, scripting, and terminal users. Headless JSON API for automation, plus a Vim-like TUI for interactive browsing and editing." license = "MIT" diff --git a/README.md b/README.md index 04a2c4c..7aa7143 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ An Excel CLI for AI, scripting, and terminal users. Inspect and read headlessly - Create, switch, and delete sheets in multi-sheet workbooks - Edit cell contents directly in the terminal - Export data to JSON format +- Select, filter, paginate, and stream read results for automation - Delete rows and columns - Search functionality with highlighting +- Read-only query preview in the TUI - Command mode for advanced operations ## Installation & Uninstallation @@ -83,6 +85,16 @@ excel-cli read rows path/to/your/file.xlsx --sheet Orders # Read rows with explicit header row (1-based) excel-cli read rows path/to/your/file.xlsx --sheet Orders --header-row 1 +# Read selected columns as records +excel-cli read records path/to/your/file.xlsx --sheet Orders --select order_id,total,status + +# Filter, paginate, and stream records as JSON Lines +excel-cli read records path/to/your/file.xlsx --sheet Orders \ + --filter status:eq:open \ + --filter total:gte:100 \ + --limit 50 \ + --output-shape jsonl + # Open interactive TUI browser excel-cli ui path/to/your/file.xlsx ``` @@ -98,6 +110,50 @@ All headless commands (`inspect`, `read`, `check`) default to JSON output. Use ` - Failure returns a non-zero exit code (see Exit Codes below) - Empty cells output `null` in JSON mode and empty string in text mode +### Reading Rows and Records + +`read rows` returns positional arrays by default. Use `--output-shape records` to return objects keyed by the resolved header row, or use `read records` when record-shaped output is the default. + +```bash +excel-cli read rows report.xlsx --sheet Orders --output-shape rows +excel-cli read rows report.xlsx --sheet Orders --output-shape records +excel-cli read records report.xlsx --sheet Orders +``` + +`--select` keeps only named columns. Names come from the resolved header row, with duplicate or blank headers made stable in the same way as `inspect columns`. + +```bash +excel-cli read records report.xlsx --sheet Orders --select order_id,customer,total +``` + +`--filter field:op:value` filters rows by column name. Repeat `--filter` to combine conditions with AND semantics. Supported operators are `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `regex`, `isnull`, and `notnull`. + +```bash +excel-cli read records report.xlsx --sheet Orders --filter status:eq:open +excel-cli read records report.xlsx --sheet Orders --filter total:gte:100 +excel-cli read records report.xlsx --sheet Orders --filter customer:contains:Inc +excel-cli read records report.xlsx --sheet Orders --filter order_id:regex:^INV-[0-9]+$ +excel-cli read records report.xlsx --sheet Orders --filter optional_note:isnull: +``` + +`--limit` and `--offset` apply after filtering. `--non-empty` removes rows where every cell is empty. No-match filters are successful queries: they return an empty `rows` or `records` array with exit code `0`. + +```bash +excel-cli read records report.xlsx --sheet Orders \ + --filter status:eq:open \ + --offset 25 \ + --limit 25 \ + --non-empty +``` + +`--output-shape jsonl` writes newline-delimited JSON records directly to stdout instead of the standard envelope. It uses the same selection, filtering, pagination, and header resolution rules as record output. + +```bash +excel-cli read records report.xlsx --sheet Orders --output-shape jsonl +``` + +Invalid selected columns, unknown filter columns, unsupported operators, malformed filters, invalid numeric comparisons, and invalid regular expressions return structured `invalid_query` errors with exit code `6`. + ### Exit Codes | Code | Meaning | @@ -316,6 +372,7 @@ The JSON files are saved in the same directory as the original Excel file. - `:nohlsearch` or `:noh` - Disable search highlighting - `:help` - Show available commands +- `:preview` or `:pv` - Show a read-only preview of the current sheet target and sample rows ## File Saving Logic diff --git a/src/cli/args.rs b/src/cli/args.rs index 2306b11..3739596 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -195,7 +195,7 @@ pub enum ReadCommands { #[arg(long)] select: Option, - /// Filter rows using field:op:value; repeat for AND semantics + /// Filter rows using field:op:value; operators: eq|ne|gt|gte|lt|lte|contains|regex|isnull|notnull; repeat for AND semantics #[arg(long = "filter")] filters: Vec, @@ -244,7 +244,7 @@ pub enum ReadCommands { #[arg(long)] select: Option, - /// Filter rows using field:op:value; repeat for AND semantics + /// Filter rows using field:op:value; operators: eq|ne|gt|gte|lt|lte|contains|regex|isnull|notnull; repeat for AND semantics #[arg(long = "filter")] filters: Vec, @@ -260,7 +260,7 @@ pub enum ReadCommands { #[arg(long)] non_empty: bool, - /// Output shape for row data + /// Output shape for row data; records by default #[arg(long, value_enum, default_value = "records")] output_shape: OutputShape, diff --git a/src/cli/read.rs b/src/cli/read.rs index 0602dae..0ea42f3 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::fs::File; use std::io::{Read, Seek}; -use std::path::Path; +use std::path::{Path, PathBuf}; use zip::ZipArchive; use crate::cli::args::{resolve_sheet_target, OutputFormat, OutputShape, ReadCommands}; @@ -46,18 +46,20 @@ pub fn handle(cmd: ReadCommands) -> Result { } => read_rows( "read.rows", false, - file, - sheet, - sheet_index, - range, - header_row, - select, - filters, - limit, - offset, - non_empty, - output_shape, - format, + RowReadRequest { + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + output_shape, + format, + }, ), ReadCommands::Records { file, @@ -75,18 +77,20 @@ pub fn handle(cmd: ReadCommands) -> Result { } => read_rows( "read.records", true, - file, - sheet, - sheet_index, - range, - header_row, - select, - filters, - limit, - offset, - non_empty, - output_shape, - format, + RowReadRequest { + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + output_shape, + format, + }, ), } } @@ -216,6 +220,21 @@ struct FilterSpec { regex: Option, } +struct RowReadRequest { + file: PathBuf, + sheet: Option, + sheet_index: Option, + range: Option, + header_row: String, + select: Option, + filters: Vec, + limit: Option, + offset: Option, + non_empty: bool, + output_shape: OutputShape, + format: OutputFormat, +} + fn invalid_query(message: impl Into) -> AppError { AppError::InvalidQuery { message: message.into(), @@ -627,19 +646,23 @@ fn read_range( fn read_rows( command: &'static str, command_requires_header: bool, - file: std::path::PathBuf, - sheet: Option, - sheet_index: Option, - range: Option, - header_row: String, - select: Option, - filters: Vec, - limit: Option, - offset: Option, - non_empty: bool, - output_shape: OutputShape, - format: OutputFormat, + request: RowReadRequest, ) -> Result { + let RowReadRequest { + file, + sheet, + sheet_index, + range, + header_row, + select, + filters, + limit, + offset, + non_empty, + output_shape, + format, + } = request; + if output_shape == OutputShape::Jsonl && matches!(format, OutputFormat::Text) { return Err(AppError::InvalidArgs { message: "--output-shape jsonl cannot be combined with --format text".to_string(), diff --git a/tests/help_and_version_cli_test.rs b/tests/help_and_version_cli_test.rs index 07d9ad0..c6fac7c 100644 --- a/tests/help_and_version_cli_test.rs +++ b/tests/help_and_version_cli_test.rs @@ -5,12 +5,11 @@ fn excel_cli_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_excel-cli")) } -#[test] -fn top_level_help_prints_to_stdout_and_exits_zero() { +fn assert_successful_help(args: &[&str]) -> String { let output = Command::new(excel_cli_bin()) - .arg("--help") + .args(args) .output() - .expect("Failed to execute excel-cli --help"); + .unwrap_or_else(|_| panic!("Failed to execute excel-cli {}", args.join(" "))); assert_eq!(output.status.code(), Some(0)); assert!( @@ -19,7 +18,12 @@ fn top_level_help_prints_to_stdout_and_exits_zero() { String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); + String::from_utf8(output.stdout).expect("help output should be valid UTF-8") +} + +#[test] +fn top_level_help_prints_to_stdout_and_exits_zero() { + let stdout = assert_successful_help(&["--help"]); assert!( stdout.contains("Usage: excel-cli "), "unexpected stdout: {stdout}" @@ -31,20 +35,7 @@ fn top_level_help_prints_to_stdout_and_exits_zero() { #[test] fn subcommand_help_prints_to_stdout_and_exits_zero() { - let output = Command::new(excel_cli_bin()) - .arg("ui") - .arg("--help") - .output() - .expect("Failed to execute excel-cli ui --help"); - - assert_eq!(output.status.code(), Some(0)); - assert!( - output.stderr.is_empty(), - "stderr should be empty: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = assert_successful_help(&["ui", "--help"]); assert!( stdout.contains("Open interactive TUI browser"), "unexpected stdout: {stdout}" @@ -55,6 +46,57 @@ fn subcommand_help_prints_to_stdout_and_exits_zero() { ); } +#[test] +fn read_help_lists_records_subcommand() { + let stdout = assert_successful_help(&["read", "--help"]); + + assert!( + stdout.contains("Read records from a sheet using a resolved header row"), + "unexpected stdout: {stdout}" + ); + assert!(stdout.contains("records"), "unexpected stdout: {stdout}"); +} + +#[test] +fn read_rows_help_documents_v12_query_flags() { + let stdout = assert_successful_help(&["read", "rows", "--help"]); + + for expected in [ + "--select ", + "--filter ", + "records by default", + "rows, records, jsonl", + ] { + assert!( + stdout.contains(expected), + "expected {expected:?} in stdout: {stdout}" + ); + } +} + #[test] fn version_prints_to_stdout_and_exits_zero() { let output = Command::new(excel_cli_bin()) @@ -70,8 +112,5 @@ fn version_prints_to_stdout_and_exits_zero() { ); let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!( - stdout.trim(), - format!("excel-cli {}", env!("CARGO_PKG_VERSION")) - ); + assert_eq!(stdout.trim(), "excel-cli 1.2.0"); } From df9a0beb60950a4c6987738333bf815ecc0daae5 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Wed, 22 Apr 2026 00:05:19 +0800 Subject: [PATCH 5/5] docs: sync README_zh.md with v1.2.0 features --- README_zh.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README_zh.md b/README_zh.md index 1d115b3..278fbb3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,8 +8,10 @@ - 在多工作表工作簿中创建、切换和删除工作表 - 直接在终端中编辑单元格内容 - 将数据导出为 JSON 格式 +- 选择、筛选、分页和流式读取结果,支持自动化场景 - 删除行和列 - 搜索功能并支持高亮显示 +- TUI 中只读查询预览 - 命令模式支持高级操作 ## 安装与卸载 @@ -83,6 +85,16 @@ excel-cli read rows path/to/your/file.xlsx --sheet Orders # 读取行并指定表头行(从 1 开始计数) excel-cli read rows path/to/your/file.xlsx --sheet Orders --header-row 1 +# 读取指定列作为记录 +excel-cli read records path/to/your/file.xlsx --sheet Orders --select order_id,total,status + +# 筛选、分页并以 JSON Lines 流式输出记录 +excel-cli read records path/to/your/file.xlsx --sheet Orders \ + --filter status:eq:open \ + --filter total:gte:100 \ + --limit 50 \ + --output-shape jsonl + # 打开交互式 TUI 浏览器 excel-cli ui path/to/your/file.xlsx ``` @@ -98,6 +110,50 @@ excel-cli ui path/to/your/file.xlsx - 失败返回非零退出码(见下方退出码说明) - 空单元格在 JSON 模式下输出 `null`,在文本模式下输出空字符串 +### 读取行与记录 + +`read rows` 默认返回位置数组。使用 `--output-shape records` 可以返回以解析后的表头为键的对象,或者直接使用 `read records`,此时记录形式输出为默认。 + +```bash +excel-cli read rows report.xlsx --sheet Orders --output-shape rows +excel-cli read rows report.xlsx --sheet Orders --output-shape records +excel-cli read records report.xlsx --sheet Orders +``` + +`--select` 保留指定的列,列名来自解析后的表头行,重复或空白的表头会按与 `inspect columns` 相同的方式处理为稳定名称。 + +```bash +excel-cli read records report.xlsx --sheet Orders --select order_id,customer,total +``` + +`--filter 字段:操作符:值` 按列名筛选行。重复 `--filter` 会以 AND 逻辑组合条件。支持的操作符有 `eq`、`ne`、`gt`、`gte`、`lt`、`lte`、`contains`、`regex`、`isnull` 和 `notnull`。 + +```bash +excel-cli read records report.xlsx --sheet Orders --filter status:eq:open +excel-cli read records report.xlsx --sheet Orders --filter total:gte:100 +excel-cli read records report.xlsx --sheet Orders --filter customer:contains:Inc +excel-cli read records report.xlsx --sheet Orders --filter order_id:regex:^INV-[0-9]+$ +excel-cli read records report.xlsx --sheet Orders --filter optional_note:isnull: +``` + +`--limit` 和 `--offset` 在筛选后生效。`--non-empty` 会移除所有单元格为空的行。未匹配的筛选仍是成功的查询:返回空的 `rows` 或 `records` 数组,退出码为 `0`。 + +```bash +excel-cli read records report.xlsx --sheet Orders \ + --filter status:eq:open \ + --offset 25 \ + --limit 25 \ + --non-empty +``` + +`--output-shape jsonl` 将换行分隔的 JSON 记录直接输出到 stdout,而不是标准的信封格式。它使用与记录输出相同的选择、筛选、分页和表头解析规则。 + +```bash +excel-cli read records report.xlsx --sheet Orders --output-shape jsonl +``` + +无效的选择列、未知的筛选列、不支持的操作符、格式错误的筛选条件、无效的数值比较和无效的正则表达式会返回结构化的 `invalid_query` 错误,退出码为 `6`。 + ### 退出码 | 代码 | 含义 | @@ -317,6 +373,7 @@ JSON 文件保存在与原始 Excel 文件相同的目录中。 - `:nohlsearch` 或 `:noh` - 关闭搜索高亮 - `:help` - 显示可用命令 +- `:preview` 或 `:pv` - 显示当前工作表目标和样本行的只读预览 ## 文件保存逻辑