Skip to main content

excel_cli/cli/
args.rs

1use clap::{Parser, Subcommand};
2use std::path::PathBuf;
3
4#[derive(Parser)]
5#[command(name = "excel-cli")]
6#[command(
7    author,
8    version,
9    about = "Excel CLI for AI, scripting, and terminal users",
10    long_about = None
11)]
12pub struct Cli {
13    #[command(subcommand)]
14    pub command: Commands,
15}
16
17#[derive(Subcommand)]
18pub enum Commands {
19    /// Inspect workbook or sheet metadata
20    Inspect {
21        #[command(subcommand)]
22        subcommand: InspectCommands,
23    },
24    /// Read cell, range, or row data
25    Read {
26        #[command(subcommand)]
27        subcommand: ReadCommands,
28    },
29    /// Check workbook or sheet data quality
30    Check {
31        /// Excel file path
32        file: PathBuf,
33
34        /// Sheet name (exact match)
35        #[arg(long)]
36        sheet: Option<String>,
37
38        /// Check rules to run, comma-separated
39        ///
40        /// Supported rules: blank_headers, duplicate_headers, blank_rows,
41        /// blank_columns, null_ratio, duplicate_values, type_drift,
42        /// formula_presence
43        #[arg(long)]
44        rules: Option<String>,
45
46        /// Minimum finding severity to return
47        ///
48        /// Findings below this threshold stay out of the response, while
49        /// data.stats.finding_count_before_threshold preserves the pre-filter
50        /// total.
51        #[arg(long, value_enum, default_value = "info")]
52        severity_threshold: SeverityThreshold,
53    },
54    /// Open interactive TUI browser
55    Ui {
56        /// Excel file path
57        file: PathBuf,
58    },
59}
60
61#[derive(Subcommand)]
62pub enum InspectCommands {
63    /// List all sheets in the workbook
64    Workbook {
65        /// Excel file path
66        file: PathBuf,
67
68        /// Output format
69        #[arg(long, value_enum, default_value = "json")]
70        format: OutputFormat,
71    },
72    /// Inspect a single sheet
73    Sheet {
74        /// Excel file path
75        file: PathBuf,
76
77        /// Sheet name (exact match)
78        #[arg(long, group = "sheet_target")]
79        sheet: Option<String>,
80
81        /// Sheet index (0-based)
82        #[arg(long, group = "sheet_target")]
83        sheet_index: Option<usize>,
84
85        /// Output format
86        #[arg(long, value_enum, default_value = "json")]
87        format: OutputFormat,
88    },
89    /// Sample data from a sheet
90    Sample {
91        /// Excel file path
92        file: PathBuf,
93
94        /// Sheet name (exact match)
95        #[arg(long, group = "sheet_target")]
96        sheet: Option<String>,
97
98        /// Sheet index (0-based)
99        #[arg(long, group = "sheet_target")]
100        sheet_index: Option<usize>,
101
102        /// Range to sample (A1 notation)
103        #[arg(long)]
104        range: Option<String>,
105
106        /// Number of rows to sample
107        #[arg(long)]
108        rows: Option<usize>,
109
110        /// Header row: auto or 1-based index
111        #[arg(long, default_value = "auto")]
112        header_row: String,
113
114        /// Output format
115        #[arg(long, value_enum, default_value = "json")]
116        format: OutputFormat,
117    },
118    /// Inspect column headers and inferred column metadata
119    Columns {
120        /// Excel file path
121        file: PathBuf,
122
123        /// Sheet name (exact match)
124        #[arg(long)]
125        sheet: String,
126
127        /// Header row: auto or 1-based index
128        #[arg(long, default_value = "auto")]
129        header_row: String,
130
131        /// Output format
132        #[arg(long, value_enum, default_value = "json")]
133        format: OutputFormat,
134    },
135    /// Detect table-like regions in a sheet
136    Tables {
137        /// Excel file path
138        file: PathBuf,
139
140        /// Sheet name (exact match)
141        #[arg(long)]
142        sheet: String,
143
144        /// Output format
145        #[arg(long, value_enum, default_value = "json")]
146        format: OutputFormat,
147    },
148}
149
150#[derive(Subcommand)]
151pub enum ReadCommands {
152    /// Read a single cell
153    Cell {
154        /// Excel file path
155        file: PathBuf,
156
157        /// Sheet name (exact match)
158        #[arg(long, group = "sheet_target")]
159        sheet: Option<String>,
160
161        /// Sheet index (0-based)
162        #[arg(long, group = "sheet_target")]
163        sheet_index: Option<usize>,
164
165        /// Cell reference (A1 notation)
166        #[arg(long)]
167        cell: String,
168
169        /// Output format
170        #[arg(long, value_enum, default_value = "json")]
171        format: OutputFormat,
172    },
173    /// Read a rectangular range
174    Range {
175        /// Excel file path
176        file: PathBuf,
177
178        /// Sheet name (exact match)
179        #[arg(long, group = "sheet_target")]
180        sheet: Option<String>,
181
182        /// Sheet index (0-based)
183        #[arg(long, group = "sheet_target")]
184        sheet_index: Option<usize>,
185
186        /// Range (A1 notation)
187        #[arg(long)]
188        range: String,
189
190        /// Output format
191        #[arg(long, value_enum, default_value = "json")]
192        format: OutputFormat,
193    },
194    /// Read rows from a sheet
195    Rows {
196        /// Excel file path
197        file: PathBuf,
198
199        /// Sheet name (exact match)
200        #[arg(long, group = "sheet_target")]
201        sheet: Option<String>,
202
203        /// Sheet index (0-based)
204        #[arg(long, group = "sheet_target")]
205        sheet_index: Option<usize>,
206
207        /// Range to read (A1 notation)
208        #[arg(long)]
209        range: Option<String>,
210
211        /// Header row: auto or 1-based index
212        #[arg(long, default_value = "auto")]
213        header_row: String,
214
215        /// Select columns by stable column name, comma-separated
216        #[arg(long)]
217        select: Option<String>,
218
219        /// Filter rows using field:op:value; operators: eq|ne|gt|gte|lt|lte|contains|regex|isnull|notnull; repeat for AND semantics
220        #[arg(long = "filter")]
221        filters: Vec<String>,
222
223        /// Maximum number of rows to return
224        #[arg(long)]
225        limit: Option<usize>,
226
227        /// Number of rows to skip after filtering
228        #[arg(long)]
229        offset: Option<usize>,
230
231        /// Drop rows where every cell in the row is empty
232        #[arg(long)]
233        non_empty: bool,
234
235        /// Output shape for row data
236        #[arg(long, value_enum, default_value = "rows")]
237        output_shape: OutputShape,
238
239        /// Output format
240        #[arg(long, value_enum, default_value = "json")]
241        format: OutputFormat,
242    },
243    /// Read records from a sheet using a resolved header row
244    Records {
245        /// Excel file path
246        file: PathBuf,
247
248        /// Sheet name (exact match)
249        #[arg(long, group = "sheet_target")]
250        sheet: Option<String>,
251
252        /// Sheet index (0-based)
253        #[arg(long, group = "sheet_target")]
254        sheet_index: Option<usize>,
255
256        /// Range to read (A1 notation)
257        #[arg(long)]
258        range: Option<String>,
259
260        /// Header row: auto or 1-based index
261        #[arg(long, default_value = "auto")]
262        header_row: String,
263
264        /// Select columns by stable column name, comma-separated
265        #[arg(long)]
266        select: Option<String>,
267
268        /// Filter rows using field:op:value; operators: eq|ne|gt|gte|lt|lte|contains|regex|isnull|notnull; repeat for AND semantics
269        #[arg(long = "filter")]
270        filters: Vec<String>,
271
272        /// Maximum number of rows to return
273        #[arg(long)]
274        limit: Option<usize>,
275
276        /// Number of rows to skip after filtering
277        #[arg(long)]
278        offset: Option<usize>,
279
280        /// Drop rows where every cell in the row is empty
281        #[arg(long)]
282        non_empty: bool,
283
284        /// Output shape for row data; records by default
285        #[arg(long, value_enum, default_value = "records")]
286        output_shape: OutputShape,
287
288        /// Output format
289        #[arg(long, value_enum, default_value = "json")]
290        format: OutputFormat,
291    },
292}
293
294#[derive(Clone, Debug, Default, clap::ValueEnum)]
295pub enum OutputFormat {
296    #[default]
297    Json,
298    Text,
299}
300
301impl OutputFormat {
302    pub fn as_str(&self) -> &str {
303        match self {
304            OutputFormat::Json => "json",
305            OutputFormat::Text => "text",
306        }
307    }
308}
309
310#[derive(Clone, Copy, Debug, Default, clap::ValueEnum, PartialEq, Eq)]
311pub enum OutputShape {
312    #[default]
313    Rows,
314    Records,
315    Jsonl,
316}
317
318impl OutputShape {
319    pub fn as_str(&self) -> &str {
320        match self {
321            OutputShape::Rows => "rows",
322            OutputShape::Records => "records",
323            OutputShape::Jsonl => "jsonl",
324        }
325    }
326}
327
328#[derive(Clone, Copy, Debug, Default, clap::ValueEnum, PartialEq, Eq)]
329pub enum SeverityThreshold {
330    #[default]
331    Info,
332    Warning,
333    Error,
334}
335
336impl SeverityThreshold {
337    pub fn as_str(&self) -> &'static str {
338        match self {
339            SeverityThreshold::Info => "info",
340            SeverityThreshold::Warning => "warning",
341            SeverityThreshold::Error => "error",
342        }
343    }
344}
345
346/// Resolve the sheet target (by name or index) to a sheet index.
347pub fn resolve_sheet_target(
348    workbook: &crate::excel::Workbook,
349    sheet: &Option<String>,
350    sheet_index: &Option<usize>,
351) -> Result<usize, crate::cli::error::AppError> {
352    use crate::cli::error::AppError;
353
354    if let Some(name) = sheet {
355        workbook
356            .resolve_sheet_by_name(name)
357            .map_err(|e| AppError::TargetNotFound {
358                message: e.to_string(),
359            })
360    } else if let Some(index) = sheet_index {
361        workbook
362            .resolve_sheet_by_index(*index)
363            .map_err(|e| AppError::TargetNotFound {
364                message: e.to_string(),
365            })
366    } else {
367        Err(AppError::InvalidArgs {
368            message: "Either --sheet or --sheet-index must be provided".to_string(),
369        })
370    }
371}