Skip to main content

excel_cli/commands/
executor.rs

1use std::path::Path;
2
3use crate::app::AppState;
4use crate::excel::{EXCEL_MAX_COLS, EXCEL_MAX_ROWS};
5use crate::json_export::{export_all_sheets_json, export_json, HeaderDirection};
6use crate::utils::{cell_reference, col_name_to_index, index_to_col_name, parse_cell_reference};
7
8impl AppState<'_> {
9    pub fn execute_command(&mut self) {
10        let command = self.input_buffer.clone();
11        self.input_mode = crate::app::InputMode::Normal;
12        self.input_buffer = String::new();
13
14        if command.is_empty() {
15            return;
16        }
17
18        // Handle cell navigation (e.g., :A1, :B10)
19        if let Some(cell_ref) = parse_cell_reference(&command) {
20            self.jump_to_cell(cell_ref);
21            return;
22        }
23
24        // Handle commands
25        match command.as_str() {
26            "w" => {
27                if let Err(e) = self.save() {
28                    self.add_notification(format!("Save failed: {e}"));
29                }
30            }
31            "wq" | "x" => self.save_and_exit(),
32            "q" => {
33                if self.workbook.is_modified() {
34                    self.add_notification(
35                        "File has unsaved changes. Use :q! to force quit or :wq to save and quit."
36                            .to_string(),
37                    );
38                } else {
39                    self.should_quit = true;
40                }
41            }
42            "q!" => self.exit_without_saving(),
43            "y" => self.copy_cell(),
44            "d" => {
45                if let Err(e) = self.cut_cell() {
46                    self.add_notification(format!("Cut failed: {e}"));
47                }
48            }
49            "put" | "pu" => {
50                if let Err(e) = self.paste_cell() {
51                    self.add_notification(format!("Paste failed: {e}"));
52                }
53            }
54            "nohlsearch" | "noh" => self.disable_search_highlight(),
55            "help" => self.show_help(),
56            "delsheet" => self.delete_current_sheet(),
57            "freeze" => self.freeze_at_cell(self.selected_cell),
58            "unfreeze" => self.clear_freeze_panes(),
59            "addsheet" => self.add_notification("Usage: :addsheet <name>".to_string()),
60            _ => {
61                // Handle commands with parameters
62                if command.starts_with("cw ") {
63                    self.handle_column_width_command(&command);
64                } else if command.starts_with("ej") {
65                    self.handle_json_export_command(&command);
66                } else if let Some(sheet_name) = command.strip_prefix("addsheet ") {
67                    self.create_sheet(sheet_name.trim());
68                } else if command.starts_with("sheet ") {
69                    let sheet_name = command.strip_prefix("sheet ").unwrap().trim();
70                    self.switch_to_sheet(sheet_name);
71                } else if command.starts_with("dr") {
72                    self.handle_delete_row_command(&command);
73                } else if command.starts_with("dc") {
74                    self.handle_delete_column_command(&command);
75                } else if let Some(cell_ref) = command.strip_prefix("freeze ") {
76                    self.handle_freeze_command(cell_ref.trim());
77                } else {
78                    self.add_notification(format!("Unknown command: {}", command));
79                }
80            }
81        }
82    }
83
84    fn handle_freeze_command(&mut self, cell_ref: &str) {
85        let Some(cell) = parse_cell_reference(cell_ref) else {
86            self.add_notification("Usage: :freeze [cell]".to_string());
87            return;
88        };
89
90        self.freeze_at_cell(cell);
91    }
92
93    fn freeze_at_cell(&mut self, cell: (usize, usize)) {
94        let (row, col) = cell;
95        if row == 1 && col == 1 {
96            self.clear_freeze_panes();
97            return;
98        }
99
100        self.workbook
101            .set_freeze_panes(row.saturating_sub(1), col.saturating_sub(1));
102        self.handle_scrolling();
103        self.add_notification(format!("Frozen panes at {}", cell_reference(cell)));
104    }
105
106    fn clear_freeze_panes(&mut self) {
107        self.workbook.clear_freeze_panes();
108        self.handle_scrolling();
109        self.add_notification("Freeze panes cleared".to_string());
110    }
111
112    fn handle_column_width_command(&mut self, cmd: &str) {
113        let parts: Vec<&str> = cmd.split_whitespace().collect();
114
115        if parts.len() < 2 {
116            self.add_notification("Usage: :cw [fit|min|number] [all]".to_string());
117            return;
118        }
119
120        let action = parts[1];
121        let apply_to_all = parts.len() > 2 && parts[2] == "all";
122
123        match action {
124            "fit" => {
125                if apply_to_all {
126                    self.auto_adjust_column_width(None);
127                } else {
128                    self.auto_adjust_column_width(Some(self.selected_cell.1));
129                }
130            }
131            "min" => {
132                if apply_to_all {
133                    // Set all columns to minimum width
134                    let sheet = self.workbook.get_current_sheet();
135                    for col in 1..=sheet.max_cols {
136                        self.column_widths[col] = 5; // Minimum width
137                    }
138                    self.add_notification("All columns set to minimum width".to_string());
139                } else {
140                    // Set current column to minimum width
141                    let col = self.selected_cell.1;
142                    self.column_widths[col] = 5; // Minimum width
143                    self.add_notification(format!("Column {} set to minimum width", col));
144                }
145            }
146            _ => {
147                // Try to parse as a number
148                if let Ok(width) = action.parse::<usize>() {
149                    let col = self.selected_cell.1;
150                    self.column_widths[col] = width.clamp(5, 50); // Clamp between 5 and 50
151                    self.add_notification(format!("Column {} width set to {}", col, width));
152                } else {
153                    self.add_notification(format!("Invalid column width: {}", action));
154                }
155            }
156        }
157    }
158
159    fn handle_delete_row_command(&mut self, cmd: &str) {
160        let parts: Vec<&str> = cmd.split_whitespace().collect();
161
162        if parts.len() == 1 {
163            // Delete current row
164            if let Err(e) = self.delete_current_row() {
165                self.add_notification(format!("Failed to delete row: {e}"));
166            }
167            return;
168        }
169
170        if parts.len() == 2 {
171            // Delete specific row
172            if let Ok(row) = parts[1].parse::<usize>() {
173                if let Err(e) = self.delete_row(row) {
174                    self.add_notification(format!("Failed to delete row {}: {}", row, e));
175                }
176            } else {
177                self.add_notification(format!("Invalid row number: {}", parts[1]));
178            }
179            return;
180        }
181
182        if parts.len() == 3 {
183            // Delete range of rows
184            if let (Ok(start_row), Ok(end_row)) =
185                (parts[1].parse::<usize>(), parts[2].parse::<usize>())
186            {
187                if let Err(e) = self.delete_rows(start_row, end_row) {
188                    self.add_notification(format!(
189                        "Failed to delete rows {} to {}: {}",
190                        start_row, end_row, e
191                    ));
192                }
193            } else {
194                self.add_notification("Invalid row range".to_string());
195            }
196            return;
197        }
198
199        self.add_notification("Usage: :dr [row] [end_row]".to_string());
200    }
201
202    fn handle_delete_column_command(&mut self, cmd: &str) {
203        let parts: Vec<&str> = cmd.split_whitespace().collect();
204
205        if parts.len() == 1 {
206            // Delete current column
207            if let Err(e) = self.delete_current_column() {
208                self.add_notification(format!("Failed to delete column: {e}"));
209            }
210            return;
211        }
212
213        if parts.len() == 2 {
214            // Delete specific column
215            let col_str = parts[1].to_uppercase();
216
217            // Try to parse as a column letter (A, B, C, etc.)
218            if let Some(col) = col_name_to_index(&col_str) {
219                if let Err(e) = self.delete_column(col) {
220                    self.add_notification(format!("Failed to delete column {}: {}", col_str, e));
221                }
222                return;
223            }
224
225            // Try to parse as a column number
226            if let Ok(col) = col_str.parse::<usize>() {
227                if let Err(e) = self.delete_column(col) {
228                    self.add_notification(format!("Failed to delete column {}: {}", col, e));
229                }
230                return;
231            }
232
233            self.add_notification(format!("Invalid column: {}", col_str));
234            return;
235        }
236
237        if parts.len() == 3 {
238            // Delete range of columns
239            let start_col_str = parts[1].to_uppercase();
240            let end_col_str = parts[2].to_uppercase();
241
242            let start_col =
243                col_name_to_index(&start_col_str).or_else(|| start_col_str.parse::<usize>().ok());
244            let end_col =
245                col_name_to_index(&end_col_str).or_else(|| end_col_str.parse::<usize>().ok());
246
247            if let (Some(start), Some(end)) = (start_col, end_col) {
248                if let Err(e) = self.delete_columns(start, end) {
249                    self.add_notification(format!(
250                        "Failed to delete columns {} to {}: {}",
251                        start_col_str, end_col_str, e
252                    ));
253                }
254            } else {
255                self.add_notification("Invalid column range".to_string());
256            }
257            return;
258        }
259
260        self.add_notification("Usage: :dc [col] [end_col]".to_string());
261    }
262
263    fn handle_json_export_command(&mut self, cmd: &str) {
264        // Check if this is an export all command
265        let export_all = cmd.starts_with("eja ") || cmd == "eja";
266
267        // Parse command
268        let parts: Vec<&str> = if cmd.starts_with("ej ") {
269            cmd.strip_prefix("ej ")
270                .unwrap()
271                .split_whitespace()
272                .collect()
273        } else if cmd == "ej" {
274            // No arguments provided, use default values
275            vec!["h", "1"] // Default to horizontal headers with 1 header row
276        } else if cmd.starts_with("eja ") {
277            cmd.strip_prefix("eja ")
278                .unwrap()
279                .split_whitespace()
280                .collect()
281        } else if cmd == "eja" {
282            // No arguments provided, use default values
283            vec!["h", "1"] // Default to horizontal headers with 1 header row
284        } else {
285            self.add_notification("Invalid JSON export command".to_string());
286            return;
287        };
288
289        // Check if we have enough arguments for direction and header count
290        if parts.len() < 2 {
291            if export_all {
292                self.add_notification("Usage: :eja [h|v] [rows]".to_string());
293            } else {
294                self.add_notification("Usage: :ej [h|v] [rows]".to_string());
295            }
296            return;
297        }
298
299        let direction_str = parts[0];
300        let header_count_str = parts[1];
301
302        let direction = match direction_str.parse::<HeaderDirection>() {
303            Ok(dir) => dir,
304            Err(_) => {
305                self.add_notification(format!(
306                    "Invalid header direction: {}. Use 'h' or 'v'",
307                    direction_str
308                ));
309                return;
310            }
311        };
312
313        let header_count = match header_count_str.parse::<usize>() {
314            Ok(count) => count,
315            Err(_) => {
316                self.add_notification(format!("Invalid header count: {}", header_count_str));
317                return;
318            }
319        };
320
321        let sheet_name = self.workbook.get_current_sheet_name();
322
323        let file_path = self.workbook.get_file_path().to_string();
324        let original_file = Path::new(&file_path);
325        let file_stem = original_file
326            .file_stem()
327            .and_then(|s| s.to_str())
328            .unwrap_or("export");
329
330        let parent_dir = original_file.parent().unwrap_or_else(|| Path::new(""));
331
332        let now = chrono::Local::now();
333        let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
334
335        let filename = if export_all {
336            format!("{}_all_sheets_{}.json", file_stem, timestamp)
337        } else {
338            format!("{}_sheet_{}_{}.json", file_stem, sheet_name, timestamp)
339        };
340
341        // Create the full path in the same directory as the original Excel file
342        let new_filepath = parent_dir.join(filename);
343
344        // Export to JSON
345        let result = if export_all {
346            export_all_sheets_json(&self.workbook, direction, header_count, &new_filepath)
347        } else {
348            export_json(
349                self.workbook.get_current_sheet(),
350                direction,
351                header_count,
352                &new_filepath,
353            )
354        };
355
356        match result {
357            Ok(_) => {
358                self.add_notification(format!("Exported to {}", new_filepath.display()));
359            }
360            Err(e) => {
361                self.add_notification(format!("Export failed: {e}"));
362            }
363        }
364    }
365
366    fn jump_to_cell(&mut self, cell_ref: (usize, usize)) {
367        let (row, col) = cell_ref; // Fixed: cell_ref is already (row, col)
368
369        if row > EXCEL_MAX_ROWS || col > EXCEL_MAX_COLS {
370            self.add_notification(format!(
371                "Cell reference out of range: {}",
372                cell_reference(cell_ref)
373            ));
374            return;
375        }
376
377        self.selected_cell = (row, col);
378        self.handle_scrolling();
379
380        self.add_notification(format!("Jumped to cell {}{}", index_to_col_name(col), row));
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::parse_cell_reference;
387    use crate::app::AppState;
388    use crate::excel::{Cell, FreezePanes, Sheet, Workbook, EXCEL_MAX_COLS, EXCEL_MAX_ROWS};
389    use std::path::PathBuf;
390
391    fn app_with_sheet() -> AppState<'static> {
392        let mut data = vec![vec![Cell::empty(); 3]; 3];
393        data[1][1] = Cell::new("Name".to_string(), false);
394        data[1][2] = Cell::new("Name".to_string(), false);
395        data[2][1] = Cell::new("Ada".to_string(), false);
396        data[2][2] = Cell::new("10".to_string(), false);
397        let sheet = Sheet {
398            name: "Data".to_string(),
399            data,
400            max_rows: 2,
401            max_cols: 2,
402            is_loaded: true,
403            freeze_panes: FreezePanes::none(),
404        };
405
406        AppState::new(
407            Workbook::from_sheets_for_test(vec![sheet]),
408            PathBuf::from("test.xlsx"),
409        )
410        .unwrap()
411    }
412
413    #[test]
414    fn parses_valid_cell_references() {
415        assert_eq!(parse_cell_reference("A1"), Some((1, 1)));
416        assert_eq!(parse_cell_reference("BC12"), Some((12, 55)));
417    }
418
419    #[test]
420    fn ignores_commands_with_non_ascii_arguments() {
421        assert_eq!(parse_cell_reference("addsheet 测试1"), None);
422        assert_eq!(parse_cell_reference("测试1"), None);
423    }
424
425    #[test]
426    fn cell_reference_command_can_jump_to_blank_cell_beyond_used_range() {
427        let mut app = app_with_sheet();
428        app.input_buffer = "A3".to_string();
429
430        app.execute_command();
431
432        assert_eq!(app.selected_cell, (3, 1));
433        assert_eq!(app.get_cell_content(3, 1), "");
434        assert_eq!(
435            app.notification_messages.last().map(String::as_str),
436            Some("Jumped to cell A3")
437        );
438    }
439
440    #[test]
441    fn cell_reference_command_can_jump_to_excel_bottom_right_cell() {
442        let mut app = app_with_sheet();
443        app.input_buffer = "XFD1048576".to_string();
444
445        app.execute_command();
446
447        assert_eq!(app.selected_cell, (EXCEL_MAX_ROWS, EXCEL_MAX_COLS));
448        assert_eq!(app.get_cell_content(EXCEL_MAX_ROWS, EXCEL_MAX_COLS), "");
449    }
450
451    #[test]
452    fn cell_reference_command_rejects_cells_beyond_excel_bounds() {
453        let mut app = app_with_sheet();
454        app.input_buffer = "XFE1048577".to_string();
455
456        app.execute_command();
457
458        assert_eq!(app.selected_cell, (1, 1));
459        assert_eq!(
460            app.notification_messages.last().map(String::as_str),
461            Some("Cell reference out of range: XFE1048577")
462        );
463    }
464
465    #[test]
466    fn freeze_command_uses_current_cell_and_marks_workbook_modified() {
467        let mut app = app_with_sheet();
468        app.selected_cell = (2, 2);
469        app.input_buffer = "freeze".to_string();
470
471        app.execute_command();
472
473        let sheet = app.workbook.get_current_sheet();
474        assert_eq!(sheet.freeze_panes.rows, 1);
475        assert_eq!(sheet.freeze_panes.cols, 1);
476        assert!(app.workbook.is_modified());
477        assert!(app.undo_history.all_undone());
478    }
479
480    #[test]
481    fn freeze_command_accepts_explicit_cell_and_a1_clears() {
482        let mut app = app_with_sheet();
483
484        app.input_buffer = "freeze B2".to_string();
485        app.execute_command();
486        assert_eq!(
487            app.workbook.get_current_sheet().freeze_panes.split_cell(),
488            (2, 2)
489        );
490
491        app.input_buffer = "freeze A1".to_string();
492        app.execute_command();
493        assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen());
494    }
495
496    #[test]
497    fn unfreeze_command_clears_freeze_panes() {
498        let mut app = app_with_sheet();
499        app.workbook.set_freeze_panes(1, 1);
500
501        app.input_buffer = "unfreeze".to_string();
502        app.execute_command();
503
504        assert!(!app.workbook.get_current_sheet().freeze_panes.is_frozen());
505    }
506}