From c9b0948762caac903084c244cf5680e68b5ec5c4 Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 2 Jul 2026 20:25:33 -0600 Subject: [PATCH 1/2] feat: add config and csv export --- README.md | 9 +++ src/config.rs | 187 ++++++++++++++++++++++++++++++++++++++------ src/csv.rs | 131 +++++++++++++++++++++++++------ src/main.rs | 23 +++++- src/menu_edit.rs | 170 ++++++++++++++++++++++++++++++---------- src/menu_main.rs | 79 +++++++++++-------- src/utils.rs | 62 ++++++++++++++- tests/assert_cmd.rs | 10 +++ 8 files changed, 546 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 730b04d..2654163 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ This is a simple app I wrote to help learn Rust and also to give me a framework Commands are stored in a cli_menu_cmd.json JSON file located at your OS-Appropriate data folder using rust [Directories]() config_dir. +## Current Features + +- Run stored shell commands from an interactive terminal menu. +- Add, edit, reorder, delete, reset, import, and export commands from the edit menu. +- Import and export command lists as CSV files with `display_name,command` headers. +- Configure an optional command sound and terminal window title. +- Run a single command directly with `--run-once`. +- Use an alternate config file with `--config /path/to/cli_menu_cmd.json`. + *Most of the initial credit goes to ChatGPT which wrote most of the code. Thanks to @Scott Pack for his talk at [BSides 2023]() which helped set the stage Sound Effects credit Pixabay [whoosh]() [message]() diff --git a/src/config.rs b/src/config.rs index 227b925..b637034 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,8 +2,10 @@ use anyhow::Context; // Importing context from the anyhow crate use directories::BaseDirs; use inquire::{Select, Text}; use serde::{Deserialize, Serialize}; // For serializing/deserializing config +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::process; // Define the Config struct with multiple sections #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] @@ -35,6 +37,16 @@ pub fn get_config_file_path() -> Result { // Get the config directory and append the file name let config_file = base_dirs.config_dir().join("cli_menu_cmd.json"); + ensure_config_file_path(config_file) +} + +/// Returns the supplied config file path, creating a default config when missing. +/// +/// # Errors +/// +/// Returns an error when an existing config cannot be loaded or a default +/// config cannot be created. +pub fn ensure_config_file_path(config_file: PathBuf) -> Result { if config_file.exists() { // Load the config for validation let config = crate::config::load_config(&config_file) @@ -46,6 +58,11 @@ pub fn get_config_file_path() -> Result { "✅ Config file loaded successfully from path: {}", config_file.display() ); + } else if let Err(errors) = validate_config(&config) { + println!("⚠️ Config loaded with validation warnings:"); + for error in errors { + println!(" - {error}"); + } } } else { println!( @@ -79,11 +96,41 @@ pub fn load_config(path: &Path) -> anyhow::Result { /// Returns an error when the config cannot be serialized or written to disk. pub fn save_config(path: &Path, config: &Config) -> anyhow::Result<()> { let config_data = serde_json::to_string_pretty(config).context("failed to serialize config")?; - fs::write(path, config_data) - .with_context(|| format!("unable to write config file at {}", path.display()))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "unable to create config directory located at {}", + parent.display() + ) + })?; + } + + let temp_path = temp_config_path(path); + fs::write(&temp_path, config_data).with_context(|| { + format!( + "unable to write temporary config file at {}", + temp_path.display() + ) + })?; + fs::rename(&temp_path, path).with_context(|| { + let _ = fs::remove_file(&temp_path); + format!( + "unable to move temporary config file from {} to {}", + temp_path.display(), + path.display() + ) + })?; Ok(()) } +fn temp_config_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("cli_menu_cmd.json"); + path.with_file_name(format!(".{file_name}.{}.tmp", process::id())) +} + // Saves a default config. fn create_default_config(path: &Path) -> anyhow::Result { let default_config = Config::default(); @@ -103,14 +150,59 @@ fn create_default_config(path: &Path) -> anyhow::Result { // Function to validate JSON config file #[must_use] pub fn validate_json(config: &Config) -> bool { - serde_json::to_string(config).is_ok() + serde_json::to_string(config).is_ok() && validate_config(config).is_ok() } -/// Prompts the user to edit the `cmd_sound` path. +/// Validates config values that can deserialize but would behave poorly at runtime. /// -/// # Panics +/// # Errors /// -/// Panics if the interactive prompt cannot read input. +/// Returns all detected validation errors so the caller can show a useful list. +pub fn validate_config(config: &Config) -> Result<(), Vec> { + let mut errors = Vec::new(); + let mut display_names = HashSet::new(); + + for (index, command) in config.commands.iter().enumerate() { + let position = index + 1; + let display_name = command.display_name.trim(); + + if display_name.is_empty() { + errors.push(format!("Command {position} has an empty display name.")); + } else if !display_names.insert(display_name.to_ascii_lowercase()) { + errors.push(format!("Duplicate display name: '{display_name}'.")); + } + + if command.command.trim().is_empty() { + errors.push(format!("Command {position} has an empty shell command.")); + } + } + + if let Some(sound_path) = &config.cmd_sound + && !sound_path.exists() + { + errors.push(format!( + "Sound file does not exist: {}.", + sound_path.display() + )); + } + + if config.window_title_support + && config + .window_title + .as_ref() + .is_some_and(|title| title.trim().is_empty()) + { + errors.push("Window title cannot be only whitespace.".to_string()); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +/// Prompts the user to edit the `cmd_sound` path. pub fn edit_cmd_sound(config: &mut Config, changes_made: &mut bool) { let current_sound = config .cmd_sound @@ -119,10 +211,17 @@ pub fn edit_cmd_sound(config: &mut Config, changes_made: &mut bool) { println!("Current sound file: {current_sound}"); - let sound_path = Text::new("Enter the new path for cmd_sound (leave empty to clear):") + let original_sound = config.cmd_sound.clone(); + let sound_path = match Text::new("Enter the new path for cmd_sound (leave empty to clear):") .with_initial_value(¤t_sound) .prompt() - .expect("Failed to read input"); + { + Ok(sound_path) => sound_path, + Err(e) => { + eprintln!("❌ Failed to read input: {e}"); + return; + } + }; if sound_path.is_empty() { config.cmd_sound = None; // Clear the cmd_sound path @@ -130,24 +229,26 @@ pub fn edit_cmd_sound(config: &mut Config, changes_made: &mut bool) { } else { let sound_path = sound_path.trim(); config.cmd_sound = Some(PathBuf::from(sound_path)); - println!( - "✅ cmd_sound updated to: {}", - config.cmd_sound.as_ref().unwrap().display() - ); + if let Some(cmd_sound) = &config.cmd_sound { + println!("✅ cmd_sound updated to: {}", cmd_sound.display()); + } } - *changes_made = true; // Mark changes as made + if config.cmd_sound != original_sound { + *changes_made = true; // Mark changes as made + } } /// Prompts the user to edit the window title settings. -/// -/// # Panics -/// -/// Panics if an interactive prompt cannot read input. pub fn edit_window_title(config: &mut Config, changes_made: &mut bool) { - let enable_title_support = Select::new("Enable window title support?", vec!["Yes", "No"]) - .prompt() - .expect("Failed to read input"); + let enable_title_support = + match Select::new("Enable window title support?", vec!["Yes", "No"]).prompt() { + Ok(value) => value, + Err(e) => { + eprintln!("❌ Failed to read input: {e}"); + return; + } + }; if enable_title_support == "Yes" { println!("✅ Window title support enabled."); @@ -164,10 +265,16 @@ pub fn edit_window_title(config: &mut Config, changes_made: &mut bool) { println!("Current window title: {current_title}"); - let new_title = Text::new("Enter the new window title (leave empty to clear):") + let new_title = match Text::new("Enter the new window title (leave empty to clear):") .with_initial_value(¤t_title) .prompt() - .expect("Failed to read input"); + { + Ok(title) => title, + Err(e) => { + eprintln!("❌ Failed to read input: {e}"); + return; + } + }; apply_window_title_settings(config, true, Some(&new_title), changes_made); @@ -263,6 +370,42 @@ mod tests { assert!(validate_json(&config)); } + #[test] + fn test_validate_config_rejects_empty_and_duplicate_commands() { + let config = Config { + commands: vec![ + CommandOption { + display_name: "List".into(), + command: "ls".into(), + }, + CommandOption { + display_name: "list".into(), + command: " ".into(), + }, + CommandOption { + display_name: " ".into(), + command: "date".into(), + }, + ], + ..Default::default() + }; + + let errors = validate_config(&config).expect_err("config should be invalid"); + + assert_eq!(errors.len(), 3); + assert!(errors.iter().any(|error| error.contains("Duplicate"))); + assert!( + errors + .iter() + .any(|error| error.contains("empty shell command")) + ); + assert!( + errors + .iter() + .any(|error| error.contains("empty display name")) + ); + } + #[test] fn test_apply_window_title_disable_without_change_stays_clean() { let mut config = Config::default(); diff --git a/src/csv.rs b/src/csv.rs index 928d96e..2df6e73 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -3,14 +3,28 @@ use crate::{ menu_edit::print_commands, utils::pause, }; -use inquire::Select; // Importing Select prompt from inquire crate +use inquire::{Select, Text}; // Importing prompts from inquire crate use std::{env, fs, path::Path}; // Importing necessary modules from standard library // Importing functions and structs from other modules +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ImportStrategy { + Append, + Overwrite, + Cancel, +} + +impl ImportStrategy { + fn from_label(label: &str) -> Self { + match label { + "a. APPEND to current commands" => Self::Append, + "o. OVERWRITE current commands" => Self::Overwrite, + _ => Self::Cancel, + } + } +} + /// Prompts for a CSV file and imports commands into the current config. /// -/// # Panics -/// -/// Panics if an interactive menu prompt cannot be displayed. pub fn import_commands(config: &mut Config, changes_made: &mut bool) { let dir = env::current_dir().unwrap_or_else(|_| ".".into()); let files = match list_csv_files(&dir) { @@ -26,9 +40,13 @@ pub fn import_commands(config: &mut Config, changes_made: &mut bool) { return; } - let path = Select::new("Select a file to import: ", files) - .prompt() - .expect("Failed to display menu"); + let path = match Select::new("Select a file to import: ", files).prompt() { + Ok(path) => path, + Err(e) => { + println!("⚠️ Import canceled: {e}"); + return; + } + }; let commands = match read_commands_from_csv(&path) { Ok(commands) => commands, @@ -48,28 +66,54 @@ pub fn import_commands(config: &mut Config, changes_made: &mut bool) { "c. CANCEL import", ]; - let menu_prompt = Select::new("Select an option: ", menu_options) - .prompt() - .expect("❌ Failed to display menu"); - - let strategy = match menu_prompt { - "a. APPEND to current commands" => "append", - "o. OVERWRITE current commands" => "overwrite", - _ => "cancel", + let menu_prompt = match Select::new("Select an option: ", menu_options).prompt() { + Ok(choice) => choice, + Err(e) => { + println!("⚠️ Import canceled: {e}"); + return; + } }; + let strategy = ImportStrategy::from_label(menu_prompt); merge_imported_commands(config, commands, strategy, changes_made); match strategy { - "append" => println!("{num_commands} commands appended."), - "overwrite" => println!("Replaced config with {num_commands} commands from {path}"), - _ => { + ImportStrategy::Append => println!("{num_commands} commands appended."), + ImportStrategy::Overwrite => { + println!("Replaced config with {num_commands} commands from {path}"); + } + ImportStrategy::Cancel => { println!("❌ Canceled import"); pause(); } } } +/// Prompts for a CSV destination and exports the current commands. +pub fn export_commands(config: &Config) { + let path = match Text::new("Enter the CSV file path to export to:") + .with_initial_value("commands.csv") + .prompt() + { + Ok(path) => path, + Err(e) => { + println!("⚠️ Export canceled: {e}"); + return; + } + }; + + let path = path.trim(); + if path.is_empty() { + println!("⚠️ Export canceled: no path provided."); + return; + } + + match write_commands_to_csv(path, &config.commands) { + Ok(()) => println!("✅ Exported {} commands to {path}.", config.commands.len()), + Err(e) => println!("❌ Could not export commands: {e}"), + } +} + /// Reads command entries from a CSV file. /// /// # Errors @@ -88,6 +132,26 @@ pub fn read_commands_from_csv>(path: P) -> anyhow::Result>( + path: P, + commands: &[CommandOption], +) -> anyhow::Result<()> { + let mut writer = csv::WriterBuilder::new() + .has_headers(false) + .from_path(path)?; + writer.write_record(["display_name", "command"])?; + for command in commands { + writer.serialize(command)?; + } + writer.flush()?; + Ok(()) +} + // Function to list CSV files in a directory fn list_csv_files(dir_path: &Path) -> anyhow::Result> { let dir_reader = fs::read_dir(dir_path)?; // Creating directory reader @@ -107,6 +171,7 @@ fn list_csv_files(dir_path: &Path) -> anyhow::Result> { files.push(name); // Adding CSV file names to the vector } } + files.sort(); Ok(files) } @@ -114,19 +179,19 @@ fn list_csv_files(dir_path: &Path) -> anyhow::Result> { fn merge_imported_commands( config: &mut Config, mut new_commands: Vec, - strategy: &str, + strategy: ImportStrategy, changes_made: &mut bool, ) { match strategy { - "append" => { + ImportStrategy::Append => { config.commands.append(&mut new_commands); *changes_made = true; } - "overwrite" => { + ImportStrategy::Overwrite => { config.commands = new_commands; *changes_made = true; } - _ => {} + ImportStrategy::Cancel => {} } } @@ -166,7 +231,7 @@ mod tests { }]; let mut changed = false; - merge_imported_commands(&mut config, new, "append", &mut changed); + merge_imported_commands(&mut config, new, ImportStrategy::Append, &mut changed); assert_eq!(config.commands.len(), 2); assert_eq!(config.commands[1].display_name, "New"); @@ -188,7 +253,7 @@ mod tests { }]; let mut changed = false; - merge_imported_commands(&mut config, new, "overwrite", &mut changed); + merge_imported_commands(&mut config, new, ImportStrategy::Overwrite, &mut changed); assert_eq!(config.commands.len(), 1); assert_eq!(config.commands[0].display_name, "Overwrite"); @@ -210,7 +275,7 @@ mod tests { }]; let mut changed = true; - merge_imported_commands(&mut config, new, "cancel", &mut changed); + merge_imported_commands(&mut config, new, ImportStrategy::Cancel, &mut changed); assert_eq!(config.commands.len(), 1); assert_eq!(config.commands[0].display_name, "Keep"); @@ -232,7 +297,7 @@ mod tests { }]; let mut changed = false; - merge_imported_commands(&mut config, new, "cancel", &mut changed); + merge_imported_commands(&mut config, new, ImportStrategy::Cancel, &mut changed); assert_eq!(config.commands.len(), 1); assert_eq!(config.commands[0].display_name, "Keep"); @@ -261,4 +326,18 @@ mod tests { read_commands_from_csv("tests/fixtures/empty.csv").expect("Should parse empty CSV"); assert!(commands.is_empty()); } + + #[test] + fn test_write_commands_to_csv_roundtrip() { + let file = tempfile::NamedTempFile::new().expect("temp file"); + let commands = vec![CommandOption { + display_name: "List Files".into(), + command: "ls -la".into(), + }]; + + write_commands_to_csv(file.path(), &commands).expect("Should write CSV"); + let loaded = read_commands_from_csv(file.path()).expect("Should parse written CSV"); + + assert_eq!(loaded, commands); + } } diff --git a/src/main.rs b/src/main.rs index d1fe322..e6b96d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,14 @@ use shell_command_menu::{config, menu_main, utils}; +use std::path::PathBuf; -fn main() { +#[tokio::main] +async fn main() { // Print the version let version = utils::get_version(); let mut args = std::env::args().skip(1); - if let Some(arg) = args.next() { + let mut config_override: Option = None; + + while let Some(arg) = args.next() { match arg.as_str() { "--version" | "-V" => { println!("{version}"); @@ -23,6 +27,13 @@ fn main() { } } } + "--config" | "-c" => { + let Some(path) = args.next() else { + eprintln!("Missing path for {arg}"); + std::process::exit(2); + }; + config_override = Some(PathBuf::from(path)); + } _ => { eprintln!("Unknown argument: {arg}"); std::process::exit(2); @@ -32,7 +43,11 @@ fn main() { println!("Welcome to CLI_Menu v{version}!"); // Execute the config::get_config_file_path function to get the config file path and load it; else create it - let config_path = match config::get_config_file_path() { + let config_path_result = match config_override { + Some(path) => config::ensure_config_file_path(path), + None => config::get_config_file_path(), + }; + let config_path = match config_path_result { Ok(path) => { path // Return the path } @@ -42,5 +57,5 @@ fn main() { } }; //Execute the display_menu function from the menu module with the config file from previous function - menu_main::display_menu(&config_path); + menu_main::display_menu(&config_path).await; } diff --git a/src/menu_edit.rs b/src/menu_edit.rs index 06d5e82..46af781 100644 --- a/src/menu_edit.rs +++ b/src/menu_edit.rs @@ -1,7 +1,7 @@ use crate::config::{ - CommandOption, Config, edit_cmd_sound, edit_window_title, save_config, validate_json, + CommandOption, Config, edit_cmd_sound, edit_window_title, save_config, validate_config, }; -use crate::csv::import_commands; +use crate::csv::{export_commands, import_commands}; use crate::menu_main::prompt_or_return; use crate::utils::pause; use inquire::Select; @@ -10,6 +10,80 @@ use std::path::Path; use std::process; use textwrap::fill; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditMenuChoice { + Add, + Edit, + Reorder, + Delete, + Reset, + Import, + Export, + Sound, + WindowTitle, + Quit, +} + +impl EditMenuChoice { + const ADD: &'static str = "a. ADD a new command"; + const EDIT: &'static str = "e. EDIT a command"; + const REORDER: &'static str = "o. REORDER a command"; + const DELETE: &'static str = "d. DELETE a command"; + const RESET: &'static str = "r. RESET (clear all commands)"; + const IMPORT: &'static str = "i. IMPORT from .csv"; + const EXPORT: &'static str = "x. EXPORT to .csv"; + const SOUND: &'static str = "s. SET sound file path"; + const WINDOW_TITLE: &'static str = "t. SET Window Title settings"; + const QUIT: &'static str = "q. Return to Main Menu (prompt to save changes)"; + + fn labels() -> Vec<&'static str> { + vec![ + Self::ADD, + Self::EDIT, + Self::REORDER, + Self::DELETE, + Self::RESET, + Self::IMPORT, + Self::EXPORT, + Self::SOUND, + Self::WINDOW_TITLE, + Self::QUIT, + ] + } + + fn from_label(label: &str) -> Option { + match label { + Self::ADD => Some(Self::Add), + Self::EDIT => Some(Self::Edit), + Self::REORDER => Some(Self::Reorder), + Self::DELETE => Some(Self::Delete), + Self::RESET => Some(Self::Reset), + Self::IMPORT => Some(Self::Import), + Self::EXPORT => Some(Self::Export), + Self::SOUND => Some(Self::Sound), + Self::WINDOW_TITLE => Some(Self::WindowTitle), + Self::QUIT => Some(Self::Quit), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SaveChoice { + Yes, + No, +} + +impl SaveChoice { + fn from_label(label: &str) -> Option { + match label { + "Yes" => Some(Self::Yes), + "No" => Some(Self::No), + _ => None, + } + } +} + pub fn edit_menu(config_path: &Path) { let mut config = crate::config::load_config(config_path).unwrap_or_else(|e| { eprintln!("Error: {e}"); @@ -22,59 +96,49 @@ pub fn edit_menu(config_path: &Path) { println!("\n🛠️ Welcome to the Edit Menu 🛠️"); print_commands(&config.commands); - let menu_options = vec![ - "a. ADD a new command", - "e. EDIT a command", - "o. REORDER a command", - "d. DELETE a command", - "r. RESET (clear all commands)", - "i. IMPORT from .csv", - "s. SET sound file path", - "t. SET Window Title settings", - "q. Return to Main Menu (prompt to save changes)", - ]; - - let menu_prompt = - prompt_or_return(|| Select::new("Select an option: ", menu_options).prompt()); + let menu_prompt = prompt_or_return(|| { + Select::new("Select an option: ", EditMenuChoice::labels()).prompt() + }); let Some(choice) = menu_prompt else { continue; }; + let Some(choice) = EditMenuChoice::from_label(choice) else { + println!("❌ Invalid choice, please try again."); + continue; + }; + match choice { - "a. ADD a new command" => add_command(&mut config, &mut changes_made), - "e. EDIT a command" => edit_command(&mut config, &mut changes_made), - "o. REORDER a command" => reorder_command(&mut config, &mut changes_made), - "d. DELETE a command" => delete_command(&mut config, &mut changes_made), - "r. RESET (clear all commands)" => clear_all_commands(&mut config, &mut changes_made), - "s. SET sound file path" => edit_cmd_sound(&mut config, &mut changes_made), - "t. SET Window Title settings" => edit_window_title(&mut config, &mut changes_made), - "i. IMPORT from .csv" => { + EditMenuChoice::Add => add_command(&mut config, &mut changes_made), + EditMenuChoice::Edit => edit_command(&mut config, &mut changes_made), + EditMenuChoice::Reorder => reorder_command(&mut config, &mut changes_made), + EditMenuChoice::Delete => delete_command(&mut config, &mut changes_made), + EditMenuChoice::Reset => clear_all_commands(&mut config, &mut changes_made), + EditMenuChoice::Sound => edit_cmd_sound(&mut config, &mut changes_made), + EditMenuChoice::WindowTitle => edit_window_title(&mut config, &mut changes_made), + EditMenuChoice::Import => { import_commands(&mut config, &mut changes_made); print!("Press any key to return to Edit Command Menu..."); pause(); } - "q. Return to Main Menu (prompt to save changes)" => { + EditMenuChoice::Export => { + export_commands(&config); + print!("Press any key to return to Edit Command Menu..."); + pause(); + } + EditMenuChoice::Quit => { if changes_made { let save_prompt = prompt_or_return(|| { Select::new("Save changes?", vec!["Yes", "No"]).prompt() }); - match save_prompt { - Some("Yes") => { - if validate_json(&config) { - match save_config(config_path, &config) { - Ok(()) => { - println!( - "✅ Changes Saved. Press any key to return to Main Menu..." - ); - pause(); - } - Err(e) => println!("❌ Error saving config: {e}"), - } - } else { - println!("❌ Error: Invalid JSON format. Changes not saved."); + let save_choice = save_prompt.and_then(SaveChoice::from_label); + match save_choice { + Some(SaveChoice::Yes) => { + if !save_current_config(config_path, &config) { + continue; } } - Some("No") => { + Some(SaveChoice::No) => { discard_edit_session(&mut config, &original_config, &mut changes_made); println!( "❌ Changes not saved. Press any key to return to Main Menu..." @@ -86,7 +150,31 @@ pub fn edit_menu(config_path: &Path) { } break; } - _ => println!("❌ Invalid choice, please try again."), + } + } +} + +fn save_current_config(config_path: &Path, config: &Config) -> bool { + match validate_config(config) { + Ok(()) => match save_config(config_path, config) { + Ok(()) => { + println!("✅ Changes Saved. Press any key to return to Main Menu..."); + pause(); + true + } + Err(e) => { + println!("❌ Error saving config: {e}"); + false + } + }, + Err(errors) => { + println!("❌ Config validation failed. Changes not saved:"); + for error in errors { + println!(" - {error}"); + } + print!("Press any key to return to Edit Command Menu..."); + pause(); + false } } } diff --git a/src/menu_main.rs b/src/menu_main.rs index fa85a68..ef19e8e 100644 --- a/src/menu_main.rs +++ b/src/menu_main.rs @@ -14,6 +14,16 @@ use crate::menu_edit::edit_menu; use inquire::error::InquireError; +const EDIT_MENU_LABEL: &str = "e. EDIT Commands"; +const EXIT_LABEL: &str = "q. EXIT"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MainMenuChoice { + Command(usize), + Edit, + Quit, +} + pub fn prompt_or_return(prompt: impl FnOnce() -> Result) -> Option { match prompt() { Ok(val) => Some(val), @@ -30,11 +40,6 @@ pub fn prompt_or_return(prompt: impl FnOnce() -> Result) -> /// Displays the main interactive command menu. /// -/// # Panics -/// -/// Panics if the Tokio runtime generated by `#[tokio::main]` cannot be -/// initialized. -#[tokio::main] pub async fn display_menu(config_path: &Path) { let mut selected_commands: Vec = vec![]; let mut last_selected: Option = None; @@ -60,8 +65,8 @@ pub async fn display_menu(config_path: &Path) { clear_screen(); let mut menu_options = generate_menu(&config.commands, &selected_commands); - menu_options.push("e. EDIT Commands".to_string()); - menu_options.push("q. EXIT".to_string()); + menu_options.push(EDIT_MENU_LABEL.to_string()); + menu_options.push(EXIT_LABEL.to_string()); let menu_prompt = if let Some(last) = last_selected { Select::new( @@ -79,48 +84,60 @@ pub async fn display_menu(config_path: &Path) { }; match menu_prompt.prompt() { - Ok(choice) => { - if choice == "q. EXIT" { + Ok(choice) => match parse_main_menu_choice(&choice) { + Some(MainMenuChoice::Quit) => { println!("Exiting CLI Menu v{}...", get_version()); exit(0); - } else if choice == "e. EDIT Commands" { + } + Some(MainMenuChoice::Edit) => { edit_menu(config_path); selected_commands.clear(); last_selected = None; - } else { - let Some(num) = choice - .split('.') - .next() - .and_then(|num_str| num_str.trim().parse::().ok()) - else { + } + Some(MainMenuChoice::Command(num)) => { + let Some(index) = num.checked_sub(1) else { println!("❌ Invalid choice, please try again."); continue; }; - - if let Some(index) = num.checked_sub(1) { - if let Some(command) = config.commands.get(index) { - if let Some(cmd_sound) = &config.cmd_sound { - tokio::spawn(play_sound(cmd_sound.clone())); - } - if config.window_title_support { - set_window_title(&choice); - } - let _ = run_command(&command.command); - selected_commands.push(num); - last_selected = Some(index); - } else { - println!("❌ Invalid choice, please try again."); + if let Some(command) = config.commands.get(index) { + if let Some(cmd_sound) = &config.cmd_sound { + tokio::spawn(play_sound(cmd_sound.clone())); + } + if config.window_title_support { + set_window_title(&choice); } + if let Err(e) = run_command(&command.command) { + eprintln!("❌ Failed to run command: {e}"); + } + selected_commands.push(num); + last_selected = Some(index); } else { println!("❌ Invalid choice, please try again."); } } - } + None => { + println!("❌ Invalid choice, please try again."); + } + }, Err(_) => println!("❌ Error reading input. Please try again."), } } } +fn parse_main_menu_choice(choice: &str) -> Option { + match choice { + EXIT_LABEL => Some(MainMenuChoice::Quit), + EDIT_MENU_LABEL => Some(MainMenuChoice::Edit), + _ => choice + .split('.') + .next()? + .trim() + .parse::() + .ok() + .map(MainMenuChoice::Command), + } +} + #[must_use] pub fn generate_menu(commands: &[CommandOption], selected_commands: &[usize]) -> Vec { let max_number_width = commands.len().to_string().len(); diff --git a/src/utils.rs b/src/utils.rs index 83e63f2..45bdce7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,6 +7,23 @@ use termion::{input::TermRead, raw::IntoRawMode}; // Importing IntoRawMode trait use tokio::task; // Importing task module from Tokio for asynchronous task handling // Importing stdout, stdin, and Write traits for I/O operations //This file contains the utility functions used in the project to run shell commands and other misc functions. +pub trait CommandExecutor { + /// Executes a shell command and returns its exit status. + /// + /// # Errors + /// + /// Returns an error when the command cannot be spawned or waited on. + fn execute(&mut self, command: &str) -> anyhow::Result; +} + +pub struct ShellCommandExecutor; + +impl CommandExecutor for ShellCommandExecutor { + fn execute(&mut self, command: &str) -> anyhow::Result { + execute_command(command) + } +} + /// Runs a shell command and prints the result, capturing stdout/stderr for cleaner output. /// /// # Errors @@ -14,8 +31,21 @@ use tokio::task; // Importing task module from Tokio for asynchronous task handl /// Returns an error when the shell cannot be spawned or the command status /// cannot be collected. pub fn run_command(command: &str) -> anyhow::Result { + let mut executor = ShellCommandExecutor; + run_command_with(command, &mut executor) +} + +/// Runs a shell command through the supplied executor. +/// +/// # Errors +/// +/// Returns an error when the executor cannot run the command. +pub fn run_command_with( + command: &str, + executor: &mut impl CommandExecutor, +) -> anyhow::Result { println!("Running command: {command}"); // Printing the command being executed - let status = execute_command(command)?; + let status = executor.execute(command)?; if status.success() { // Checking if the command was successful @@ -106,6 +136,22 @@ mod tests { use serial_test::serial; // Importing serial_test crate for running tests serially for command execution tests + #[cfg(unix)] + struct FakeExecutor { + status_code: i32, + commands: Vec, + } + + #[cfg(unix)] + impl CommandExecutor for FakeExecutor { + fn execute(&mut self, command: &str) -> anyhow::Result { + use std::os::unix::process::ExitStatusExt; + + self.commands.push(command.to_string()); + Ok(ExitStatus::from_raw(self.status_code << 8)) + } + } + #[test] #[serial] fn test_run_command_success() { @@ -120,6 +166,20 @@ mod tests { assert!(!status.success()); } + #[cfg(unix)] + #[test] + fn test_run_command_with_uses_injected_executor() { + let mut executor = FakeExecutor { + status_code: 0, + commands: Vec::new(), + }; + + let status = run_command_with("echo fake", &mut executor).expect("command should run"); + + assert!(status.success()); + assert_eq!(executor.commands, vec!["echo fake"]); + } + #[tokio::test] #[serial] async fn test_play_sound() { diff --git a/tests/assert_cmd.rs b/tests/assert_cmd.rs index 747dd09..aa279e0 100644 --- a/tests/assert_cmd.rs +++ b/tests/assert_cmd.rs @@ -13,3 +13,13 @@ fn run_once_executes_command_and_reports_success() { assert!(stdout.contains("assert_cmd_ok")); assert!(stdout.contains("Command executed successfully.")); } + +#[test] +fn config_requires_path_argument() { + Command::cargo_bin("shell_command_menu") + .expect("binary should build") + .arg("--config") + .assert() + .code(2) + .stderr("Missing path for --config\n"); +} From c3f27e0451dba7e625ae9d4ce1e3fedb0d93daf9 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 3 Jul 2026 19:01:33 -0600 Subject: [PATCH 2/2] feat: Upgrade to 0.3.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/changelog.md | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e9fc6a..e62c1eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1233,7 +1233,7 @@ dependencies = [ [[package]] name = "shell_command_menu" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 447f974..ef8137d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shell_command_menu" -version = "0.3.0" +version = "0.3.1" edition = "2024" [dependencies] diff --git a/src/changelog.md b/src/changelog.md index 0441951..b3740d7 100644 --- a/src/changelog.md +++ b/src/changelog.md @@ -1,5 +1,9 @@ # Changelog +07/3/26 - v0.3.1 +Added export and config paths +Added config validation and command execution tests + 5/4/26 - v0.3.0 Code update with signficant refactor based on latest review