diff --git a/README.md b/README.md index a10250d..ea091c2 100644 --- a/README.md +++ b/README.md @@ -235,11 +235,22 @@ halp [OPTIONS] plz ``` Options: - -m, --man-cmd Sets the manual page command to run - --cheat-sh-url Use a custom URL for cheat.sh [env: CHEAT_SH_URL=] - -p, --pager Sets the pager to use - --no-pager Disables the pager - -h, --help Print help + -p, --pager + Sets the pager to use + + --no-pager + Disables the pager + + -s, --selected-position + Sets the default selected position + + Possible values: + - start: The first item in the menu + - center: The middle item in the menu (in case of even number of items, the first item in the second half) + - end: The last item in the menu + + -h, --help + Print help (see a summary with '-h') ``` ## Examples @@ -313,12 +324,6 @@ To disable the pager: halp plz --no-pager bat vim ``` -##### Custom cheat.sh host URL - -```sh -halp plz --cheat-sh-url https://cht.sh vim -``` - ## Configuration `halp` can be configured with a configuration file that uses the [TOML](https://en.wikipedia.org/wiki/INI_file) format. It can be specified via `--config` or `HALP_CONFIG` environment variable. It can also be placed in one of the following global locations: @@ -330,7 +335,7 @@ halp plz --cheat-sh-url https://cht.sh vim `` depends on the platform as shown in the following table: | Platform | Value | Example | -| -------- | ------------------------------------- | ---------------------------------------- | +|----------|---------------------------------------|------------------------------------------| | Linux | `$XDG_CONFIG_HOME` or `$HOME`/.config | /home/orhun/.config | | macOS | `$HOME`/Library/Application Support | /Users/Orhun/Library/Application Support | | Windows | `{FOLDERID_RoamingAppData}` | C:\Users\Orhun\AppData\Roaming | diff --git a/config/halp.toml b/config/halp.toml index 29835de..1533f06 100644 --- a/config/halp.toml +++ b/config/halp.toml @@ -1,14 +1,33 @@ -# configuration for https://github.com/orhun/halp - -# check the version flag check_version = true -# check the help flag check_help = true -# arguments to check -check = [ [ "-v", "-V", "--version" ], [ "-h", "--help", "help", "-H" ] ] -# command to run for manual pages +check = [["-v", "-V", "--version", "version"], ["-h", "--help", "help", "-H"]] man_command = "man" -# pager to use for command outputs pager_command = "less -R" -# Cheat.sh URL -cheat_sh_url = "https://cheat.sh" + +[plz_menu] +selected_pos = "Center" + +[[plz_menu.entries]] +display_msg = "Show man page" + +[plz_menu.entries.operation] +run = "man {cmd}" + +[[plz_menu.entries]] +display_msg = "Show cheat.sh page" + +[plz_menu.entries.operation] +user-agent = "fetch" +fetch = "https://cheat.sh/{cmd}{?/{subcommand}}{? {args}}" + +[[plz_menu.entries]] +display_msg = "Show eg page" + +[plz_menu.entries.operation] +fetch = "https://raw.githubusercontent.com/srsudar/eg/master/eg/examples/{cmd}.md" + +[[plz_menu.entries]] +display_msg = "Show cheatsheets page" + +[plz_menu.entries.operation] +fetch = "https://raw.githubusercontent.com/cheat/cheatsheets/master/{cmd}" diff --git a/src/cli.rs b/src/cli.rs index 4da1430..b115e8f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use crate::config::plz_menu::PlzMenuSelection; use crate::config::Config; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -45,24 +46,15 @@ pub enum CliCommands { Plz { /// Command or binary name. cmd: String, - /// Sets the manual page command to run. - #[arg(short, long)] - man_cmd: Option, - /// Use a custom URL for cheat.sh. - #[arg(long, env = "CHEAT_SH_URL", value_name = "URL")] - cheat_sh_url: Option, - /// Use a custom provider URL for `eg` pages. - #[arg(long, env = "EG_PAGES_URL", value_name = "URL")] - eg_url: Option, - /// Use a custom URL for cheat sheets. - #[arg(long, env = "CHEATSHEETS_URL", value_name = "URL")] - cheat_url: Option, /// Sets the pager to use. #[arg(short, long)] pager: Option, /// Disables the pager. #[arg(long)] no_pager: bool, + /// Sets the default selected position. + #[arg(long, short)] + selected_position: Option, }, } @@ -80,28 +72,20 @@ impl CliArgs { config.check_args = Some(args.iter().map(|s| vec![s.to_string()]).collect()); } if let Some(CliCommands::Plz { - ref man_cmd, - ref cheat_sh_url, - ref eg_url, no_pager, ref pager, + ref selected_position, .. }) = self.subcommand { - if let Some(man_cmd) = man_cmd { - config.man_command = man_cmd.clone(); - } - if let Some(cheat_sh_url) = cheat_sh_url { - config.cheat_sh_url = Some(cheat_sh_url.clone()); - } - if let Some(eg_url) = eg_url { - config.eg_url = Some(eg_url.to_owned()); - } if no_pager { config.pager_command = None; } else if let Some(pager) = pager { config.pager_command = Some(pager.clone()); } + if let Some(selected_position) = selected_position { + config.plz_menu.selected_pos = *selected_position; + } } } } @@ -125,10 +109,7 @@ mod tests { subcommand: Some(CliCommands::Plz { cmd: "ps".to_string(), pager: Some("bat".to_string()), - cheat_sh_url: None, - cheat_url: None, - eg_url: None, - man_cmd: None, + selected_position: Some(PlzMenuSelection::Center), no_pager: false, }), ..Default::default() diff --git a/src/config.rs b/src/config/mod.rs similarity index 83% rename from src/config.rs rename to src/config/mod.rs index ab04314..dce8372 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -1,9 +1,10 @@ +/// The `plz` configuration stuff. +pub mod plz_menu; + +use crate::config::plz_menu::PlzMenu; use crate::error::Result; use crate::helper::args::common::{HelpArg, VersionArg}; use crate::helper::args::FOUND_EMOTICON; -use crate::helper::docs::cheat_sh::DEFAULT_CHEAT_SHEET_PROVIDER; -use crate::helper::docs::cheatsheets::DEFAULT_CHEATSHEETS_PROVIDER; -use crate::helper::docs::eg::DEFAULT_EG_PAGES_PROVIDER; use colored::*; use serde::{Deserialize, Serialize}; use std::env; @@ -25,12 +26,8 @@ pub struct Config { pub man_command: String, /// Pager to use for command outputs, None to disable. pub pager_command: Option, - /// Use a custom URL for cheat.sh. - pub cheat_sh_url: Option, - /// Use a custom URL for `eg` pages provider. - pub eg_url: Option, - /// Use a custom URL for cheatsheets provider. - pub cheatsheets_url: Option, + /// Plz menu options. + pub plz_menu: PlzMenu, } impl Default for Config { @@ -50,9 +47,7 @@ impl Default for Config { ]), man_command: "man".to_string(), pager_command: Some("less -R".to_string()), - cheat_sh_url: Some(DEFAULT_CHEAT_SHEET_PROVIDER.to_string()), - eg_url: Some(DEFAULT_EG_PAGES_PROVIDER.to_string()), - cheatsheets_url: Some(DEFAULT_CHEATSHEETS_PROVIDER.to_string()), + plz_menu: PlzMenu::default(), } } } @@ -122,7 +117,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; use std::path::PathBuf; #[test] @@ -136,10 +130,6 @@ mod tests { let config = Config::parse(&path)?; assert!(config.check_help); assert!(config.check_version); - assert_eq!( - config.cheat_sh_url, - Some(DEFAULT_CHEAT_SHEET_PROVIDER.to_string()) - ); Ok(()) } } diff --git a/src/config/plz_menu.rs b/src/config/plz_menu.rs new file mode 100644 index 0000000..10b052e --- /dev/null +++ b/src/config/plz_menu.rs @@ -0,0 +1,83 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Default cheat sheet provider URL. +const CHEAT_SH_URL_TEMPLATE: &str = "https://cheat.sh/{cmd}{?/{subcommand}}{? {args}}"; + +/// User agent for the cheat sheet provider. +/// +/// See +const CHEAT_SH_USER_AGENT: &str = "fetch"; + +/// EG page provider URL. +const EG_PAGES_URL_TEMPLATE: &str = + "https://raw.githubusercontent.com/srsudar/eg/master/eg/examples/{cmd}.md"; + +/// The default cheatsheets provider URL. +const CHEATSHEETS_URL_TEMPLATE: &str = + "https://raw.githubusercontent.com/cheat/cheatsheets/master/{cmd}"; + +/// Plz menu config. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlzMenu { + /// The default selected poison in the menu. + pub selected_pos: PlzMenuSelection, + /// The menu entries. + pub entries: Vec, +} + +/// Plz menu selection position. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ValueEnum)] +pub enum PlzMenuSelection { + /// The first item in the menu. + Start, + /// The middle item in the menu (in case of even number of items, the first item in the second half). + Center, + /// The last item in the menu. + End, +} + +/// Plz menu item. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlzMenuEntry { + /// The string to display in the menu. + pub display_msg: String, + /// The operation to perform. and its arguments. + pub operation: HashMap, +} + +impl Default for PlzMenu { + fn default() -> Self { + PlzMenu { + selected_pos: PlzMenuSelection::Center, + entries: vec![ + PlzMenuEntry { + display_msg: "Show man page".to_string(), + operation: HashMap::from([("run".to_string(), "man {cmd}".to_string())]), + }, + PlzMenuEntry { + display_msg: "Show cheat.sh page".to_string(), + operation: HashMap::from([ + ("fetch".to_string(), CHEAT_SH_URL_TEMPLATE.to_string()), + ("user-agent".to_string(), CHEAT_SH_USER_AGENT.to_string()), + ]), + }, + PlzMenuEntry { + display_msg: "Show eg page".to_string(), + operation: HashMap::from([( + "fetch".to_string(), + EG_PAGES_URL_TEMPLATE.to_string(), + )]), + }, + PlzMenuEntry { + display_msg: "Show cheatsheets page".to_string(), + operation: HashMap::from([( + "fetch".to_string(), + CHEATSHEETS_URL_TEMPLATE.to_string(), + )]), + }, + ], + } + } +} diff --git a/src/error.rs b/src/error.rs index fbbdb09..8620e63 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use crate::helper::docs::template::HalpTemplateError; use thiserror::Error as ThisError; /// Custom error type. @@ -21,6 +22,24 @@ pub enum Error { /// Error that might occur when tray to get help from an external provider. #[error("External help provider error: `{0}`")] ProviderError(String), + /// Error that might occur when trying to parse a template. + #[error("Template error: `{0}`")] + TemplateError(#[from] HalpTemplateError), + /// Error that might occur if the user provides invalid arguments for the operation handler. + #[error("Invalid argument: `{0}`")] + InvalidArgument(String), + /// Error that might occur if the user dosen't provide any operation handler. + #[error("No operation provided.")] + PlzMenuNoOperation, + /// Error that might occur if the user provides an invalid operation. + #[error("Invalid operation there is no operation named `{0}`.")] + PlzMenuInvalidOperation(String), + /// Error that might occur if the timeout is reached while executing a command. + #[error("Command timeout.")] + CommandTimeoutError, + /// Error that might occur when trying to execute a command or collect its output. + #[error("Command error: `{0}`")] + CommandError(String), } /// Type alias for the standard [`Result`] type. diff --git a/src/helper/args/mod.rs b/src/helper/args/mod.rs index 9573ba3..f437bbd 100644 --- a/src/helper/args/mod.rs +++ b/src/helper/args/mod.rs @@ -87,7 +87,7 @@ fn check_args<'a, ArgsIter: Iterator, Output: Write>( /// Shows command-line help about the given command. pub fn get_args_help( cmd: &str, - config: &Config, + config: Config, verbose: bool, output: &mut Output, ) -> Result<()> { @@ -215,7 +215,7 @@ Options: fn test_get_default_help() -> Result<()> { let config = Config::default(); let mut output = Vec::new(); - get_args_help(&get_test_bin(), &config, false, &mut output)?; + get_args_help(&get_test_bin(), config, false, &mut output)?; println!("{}", String::from_utf8_lossy(&output)); assert_eq!( r"(°ロ°) checking 'test -v' @@ -250,7 +250,7 @@ Options: ..Default::default() }; let mut output = Vec::new(); - get_args_help(&get_test_bin(), &config, false, &mut output)?; + get_args_help(&get_test_bin(), config, false, &mut output)?; println!("{}", String::from_utf8_lossy(&output)); assert_eq!( r"(°ロ°) checking 'test -x' @@ -277,7 +277,7 @@ halp 0.1.0 ..Default::default() }; let mut output = Vec::new(); - get_args_help("", &config, false, &mut output)?; + get_args_help("", config, false, &mut output)?; assert!(String::from_utf8_lossy(&output).is_empty()); Ok(()) } diff --git a/src/helper/docs/cheat_sh.rs b/src/helper/docs/cheat_sh.rs deleted file mode 100644 index 2051452..0000000 --- a/src/helper/docs/cheat_sh.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::error::{Error, Result}; -use crate::helper::docs::HelpProvider; -use ureq::{AgentBuilder, Request}; - -/// Default cheat sheet provider URL. -pub const DEFAULT_CHEAT_SHEET_PROVIDER: &str = "https://cheat.sh"; - -/// User agent for the cheat sheet provider. -/// -/// See -const CHEAT_SHEET_USER_AGENT: &str = "fetch"; - -/// The `cheat.sh` provider -pub struct CheatDotSh; - -impl HelpProvider for CheatDotSh { - fn url(&self) -> &'static str { - DEFAULT_CHEAT_SHEET_PROVIDER - } - - fn build_request(&self, cmd: &str, url: &str) -> Request { - AgentBuilder::new() - .user_agent(CHEAT_SHEET_USER_AGENT) - .build() - .get(&format!("{}/{}", url, cmd)) - } - - fn fetch(&self, cmd: &str, custom_url: &Option) -> Result { - let response = self._fetch(cmd, custom_url); - if let Ok(page) = &response { - if page.starts_with("Unknown topic.") { - return Err(Error::ProviderError(page.to_owned())); - } - } - response - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fetch_cheat_sheet() -> Result<()> { - let output = CheatDotSh.fetch("ls", &None)?; - assert!(output.contains( - "# To display all files, along with the size (with unit suffixes) and timestamp:" - )); - assert!(output.contains( - "# Long format list with size displayed using human-readable units (KiB, MiB, GiB):" - )); - Ok(()) - } -} diff --git a/src/helper/docs/cheatsheets.rs b/src/helper/docs/cheatsheets.rs deleted file mode 100644 index 2375b92..0000000 --- a/src/helper/docs/cheatsheets.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::helper::docs::HelpProvider; -use ureq::{AgentBuilder, Request}; - -/// The default cheatsheets provider URL. -pub const DEFAULT_CHEATSHEETS_PROVIDER: &str = - "https://raw.githubusercontent.com/cheat/cheatsheets/master"; - -/// The `cheatsheets` provider -pub struct Cheatsheets; - -impl HelpProvider for Cheatsheets { - fn url(&self) -> &'static str { - DEFAULT_CHEATSHEETS_PROVIDER - } - - fn build_request(&self, cmd: &str, url: &str) -> Request { - AgentBuilder::new().build().get(&format!("{}/{}", url, cmd)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::Result; - - #[test] - fn test_fetch_cheatsheets() -> Result<()> { - let output = Cheatsheets.fetch("ls", &None)?; - assert!(output.contains( - r##"# To display everything in , including hidden files: -ls -a -"## - )); - assert!(output.contains( - r##"# To display directories only, include hidden: -ls -d .*/ */ -"## - )); - Ok(()) - } -} diff --git a/src/helper/docs/cmd_parse.rs b/src/helper/docs/cmd_parse.rs new file mode 100644 index 0000000..1c04457 --- /dev/null +++ b/src/helper/docs/cmd_parse.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; + +/// Parses a command string into a HashMap that contains the command parts. +/// +/// the command string is expected to be in the format: +/// ` [] []` +/// the subcommand and args are optional. +/// +/// This function will add at least the `cmd` key to the `values_map` and 3 keys at most. +/// - `cmd`: The command name. +/// - `subcommand`: The command subcommand. +/// - `args`: The command arguments. +/// +/// # Example +/// ``` +/// # use halp::helper::docs::cmd_parse::parse_cmd; +/// # use std::collections::HashMap; +/// +/// let git_commit = "git commit -a"; +/// let mut values_map = HashMap::with_capacity(3); +/// parse_cmd(git_commit, &mut values_map); +/// assert!(values_map.contains_key("cmd")); +/// assert_eq!(values_map.get("cmd").unwrap(), "git"); +/// assert!(values_map.contains_key("subcommand")); +/// assert_eq!(values_map.get("subcommand").unwrap(), "commit"); +/// assert!(values_map.contains_key("args")); +/// assert_eq!(values_map.get("args").unwrap(), "-a"); +/// ``` +/// +/// # Panics +/// THIS FUNCTION WILL PANIC IF THE COMMAND STRING IS EMPTY. +pub fn parse_cmd(cmd: &str, values_map: &mut HashMap) { + let cmd = cmd.trim().to_string(); + let mut iter = cmd.split_whitespace(); + // The `cmd` should be the first value. + values_map.insert( + "cmd".to_string(), + iter.next().expect("The command should exist").to_string(), + ); + // Parse the rest of the command parts. + for part in iter { + if !part.starts_with('-') && !values_map.contains_key("subcommand") { + values_map.insert("subcommand".to_string(), part.to_string()); + } else if !values_map.contains_key("args") { + values_map.insert("args".to_string(), part.to_string()); + } else { + // The args already exists, so we append the part to it. + let args = values_map.get_mut("args").expect("Unreachable"); + args.push_str(&format!(" {}", part)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_parse_complete_cmd() { + let git_commit = "git commit -a"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(git_commit, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"git".to_string())); + assert!(values_map.contains_key("subcommand")); + assert_eq!(values_map.get("subcommand"), Some(&"commit".to_string())); + assert!(values_map.contains_key("args")); + assert_eq!(values_map.get("args"), Some(&"-a".to_string())); + } + + #[test] + fn test_parse_cmd_with_no_args() { + let git_commit = "git commit"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(git_commit, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"git".to_string())); + assert!(values_map.contains_key("subcommand")); + assert_eq!(values_map.get("subcommand"), Some(&"commit".to_string())); + assert!(!values_map.contains_key("args")); + } + + #[test] + fn test_parse_cmd_with_no_subcommand() { + let git_commit = "git"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(git_commit, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"git".to_string())); + assert!(!values_map.contains_key("subcommand")); + assert!(!values_map.contains_key("args")); + } + + #[test] + fn test_parse_cmd_with_no_subcommand_and_args() { + let git_commit = "git"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(git_commit, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"git".to_string())); + assert!(!values_map.contains_key("subcommand")); + assert!(!values_map.contains_key("args")); + } + + #[test] + fn test_parse_cmd_with_args_and_no_subcommand() { + let command = "ps -aux"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(command, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"ps".to_string())); + assert!(!values_map.contains_key("subcommand")); + assert!(values_map.contains_key("args")); + assert_eq!(values_map.get("args"), Some(&"-aux".to_string())); + } + + #[test] + fn test_parse_cmd_with_two_args_and_no_subcommand() { + let command = "ps -aux -l"; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(command, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"ps".to_string())); + assert!(!values_map.contains_key("subcommand")); + assert!(values_map.contains_key("args")); + assert_eq!(values_map.get("args"), Some(&"-aux -l".to_string())); + } + + #[test] + fn test_parse_cmd_with_three_args_and_subcommand() { + let command = "git commit -a -m \"commit message\""; + let mut values_map = HashMap::with_capacity(3); + parse_cmd(command, &mut values_map); + assert!(values_map.contains_key("cmd")); + assert_eq!(values_map.get("cmd"), Some(&"git".to_string())); + assert!(values_map.contains_key("subcommand")); + assert_eq!(values_map.get("subcommand"), Some(&"commit".to_string())); + assert!(values_map.contains_key("args")); + assert_eq!( + values_map.get("args"), + Some(&"-a -m \"commit message\"".to_string()) + ); + } +} diff --git a/src/helper/docs/eg.rs b/src/helper/docs/eg.rs index a305634..3c3e8f8 100644 --- a/src/helper/docs/eg.rs +++ b/src/helper/docs/eg.rs @@ -31,9 +31,9 @@ mod tests { assert!(output.contains("show contents of current directory")); assert!(output.contains("ls -alh")); assert!(output.contains( - r#"`ls` is often aliased to make the defaults a bit more useful. Here are three + r##"`ls` is often aliased to make the defaults a bit more useful. Here are three basic aliases. The second two can be remembered by "list long" and "list all": -"# +"## )); Ok(()) } diff --git a/src/helper/docs/handlers/command.rs b/src/helper/docs/handlers/command.rs new file mode 100644 index 0000000..1fd0dbf --- /dev/null +++ b/src/helper/docs/handlers/command.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +use crate::error::{Error, Result}; +use crate::helper::docs::handlers::Handler; + +/// External command operation handler. +pub struct CommandHandler; + +impl Handler for CommandHandler { + /// Execute an external command. + /// + /// # Arguments + /// - `command_str`: The command to execute. + /// - `args_map`: The arguments map. + /// + /// ## Possible arguments that can be put in the arguments map + /// - `cwd`: The command working directory, default is the current working directory. + /// - `env`: The command environment variables in the format `key=value`, default is the current environment variables. + /// - `timeout`: The command timeout in seconds, default is infinite. + /// - `use-pager`: Use a pager to display the command output, default is `false`. + fn handle( + &self, + command_str: String, + args_map: &HashMap, + ) -> Result> { + let mut command = create_command(&command_str); + if let Some(cwd) = args_map.get("cwd") { + command.current_dir(cwd); + } + if let Some(env) = args_map.get("env") { + command.envs(parse_env(env)?); + } + execute_command(command, args_map) + } +} + +/// Parse the environment variables string in`key=value,key=value` format. +fn parse_env(env: &str) -> Result> { + let mut env_map = HashMap::new(); + for env in env.split(',') { + let split = env.split_once('='); + if let Some((key, value)) = split { + env_map.insert(key.to_string(), value.to_string()); + } else { + return Err(Error::InvalidArgument("env".to_string())); + } + } + Ok(env_map) +} + +/// Execute the command. +/// if the timeout is 0 then execute the command without a timeout. +macro_rules! execute { + ($command: ident, $timeout: expr) => {{ + let timeout = $timeout; + if timeout > 0 { + spawn_with_timeout($command, $timeout)? + } else { + $command.spawn()? + } + }}; +} + +/// Collect the output of the command to string. +macro_rules! collect_command_output { + ($output: expr) => {{ + let output = String::from_utf8($output.stdout) + .map_err(|_| Error::CommandError("Failed to read the command output".to_string()))?; + output + }}; +} + +/// Execute the command and pipe the output to another command if needed. +#[inline(always)] +fn execute_command( + mut command: Command, + args_map: &HashMap, +) -> Result> { + let use_pager = if let Some(use_pager) = args_map.get("use-pager") { + if use_pager == "true" || use_pager == "1" { + // Set the stdout to the pipe configuration. + command.stdout(Stdio::piped()); + true + } else { + false + } + } else { + false + }; + let mut process = execute!( + command, + if let Some(timeout) = args_map.get("timeout") { + timeout + .parse::() + .map_err(|_| Error::InvalidArgument("timeout".to_string()))? + } else { + 0 + } + ); + // If the `use-pager` argument is set to `true` then collect the output to return it later. + Ok(if use_pager { + Some(collect_command_output!(process.wait_with_output()?)) + } else { + process.wait()?; + None + }) +} + +/// Spawn(execute) a command with for a specified time. +/// +/// if the timeout is reached the execution will be terminated and an error will be returned. +/// +///# Panics +/// This function will panic if the command thread failed to join. +fn spawn_with_timeout(mut command: Command, timeout: u64) -> Result { + let execute_thread = thread::spawn(move || command.spawn()); + // Wait for the command for the specified timeout. + for _ in 0..timeout { + if execute_thread.is_finished() { + break; + } + thread::sleep(Duration::from_secs(1)); + } + // If the command is still running, kill it and return an error. + if !execute_thread.is_finished() { + return Err(Error::CommandTimeoutError); + } + Ok(execute_thread + .join() + .expect("Failed to join the command thread.")?) +} + +fn create_command(cmd: &str) -> Command { + let mut command = if cfg!(target_os = "windows") { + let mut command = Command::new("cmd"); + command.args(["/C", cmd]); + command + } else { + let mut command = Command::new("sh"); + command.args(["-c", cmd]); + command + }; + command.stdin(Stdio::piped()); + command +} diff --git a/src/helper/docs/handlers/fetch.rs b/src/helper/docs/handlers/fetch.rs new file mode 100644 index 0000000..a9e5676 --- /dev/null +++ b/src/helper/docs/handlers/fetch.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; +use std::time::Duration; +use ureq::{AgentBuilder, Proxy}; + +use crate::error::{Error, Result}; +use crate::helper::docs::handlers::Handler; + +/// Fetch pages from an external source by http. +/// +/// # Examples +/// +/// ``` +/// # use std::collections::HashMap; +/// # use halp::helper::docs::handlers::fetch::FetchHandler; +/// # use halp::error::Result; +/// # use halp::helper::docs::handlers::Handler; +/// +/// let git_cheat_page = FetchHandler.handle("https://cheat.sh/git".to_string(), &HashMap::new()); +/// assert!(git_cheat_page.is_ok()); +/// let git_cheat_page = git_cheat_page.unwrap(); +/// assert!(git_cheat_page.is_some()); +/// println!("{}", git_cheat_page.unwrap()); +/// ``` +pub struct FetchHandler; + +impl Handler for FetchHandler { + /// Fetch the help page from an external source by http. + /// + /// The first argument is the URL to fetch, the rest of the arguments is used to configure the request. + /// The first argument is required, the rest is optional. + /// + /// The possible arguments are: + /// - `method`: The HTTP method to use, default is `GET`. + /// - `body`: The request body, default is empty. + /// - `headers`: The request headers, default is empty. + /// - `timeout`: The request timeout in seconds, default is 10 seconds. + /// - `user-agent`: The request user agent, default is `help me plz - `. + /// - `proxy`: The request proxy, default is empty. + fn handle( + &self, + op_value: String, + args_map: &HashMap, + ) -> Result> { + // build request + let mut agent_builder = AgentBuilder::new().user_agent( + args_map + .get("user-agent") + .unwrap_or(&format!("help me plz - {}", env!("CARGO_PKG_VERSION")).to_string()), + ); + if let Some(proxy) = args_map.get("proxy") { + agent_builder = agent_builder + .proxy(Proxy::new(proxy).map_err(|_| Error::InvalidArgument("proxy".to_string()))?) + } + let agent = agent_builder.build(); + let mut request = agent + .request( + args_map.get("method").unwrap_or(&"GET".to_string()), + &op_value, + ) + .timeout(if let Some(timeout) = args_map.get("timeout") { + Duration::from_secs( + timeout + .parse::() + .map_err(|_| Error::InvalidArgument("timeout".to_string()))?, + ) + } else { + Duration::from_secs(10) + }); + // add headers if any + if let Some(headers) = args_map.get("headers") { + for header in headers.split(',') { + let mut header = header.split(':'); + request = request.set( + header.next().unwrap_or("").trim(), + header.next().unwrap_or("").trim(), + ); + } + } + let request = if let Some(body) = args_map.get("body") { + request.send_string(body) + } else { + request.call() + } + .map_err(|e| Error::from(Box::new(e)))?; + let response = request + .into_string() + .map_err(|e| Error::ProviderError(e.to_string()))?; + // handle potential errors + if response.is_empty() + || response.contains("Unknown topic") + || response.contains("No manual entry") + { + return Err(Error::ProviderError( + "Unknown topic, This topic/command might has no page in this provider yet." + .to_string(), + )); + } + Ok(Some(response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_fetch_cheat_sheet() -> Result<()> { + let output = FetchHandler.handle( + "https://cheat.sh/ls".to_string(), + &HashMap::from([("user-agent".to_string(), "fetch".to_string())]), + )?; + let output = output.expect("output is empty"); + assert!(output.contains( + "# To display all files, along with the size (with unit suffixes) and timestamp:" + )); + assert!(output.contains( + "# Long format list with size displayed using human-readable units (KiB, MiB, GiB):" + )); + Ok(()) + } + + #[test] + fn test_fetch_unknown_topic() { + let output = FetchHandler.handle( + "https://cheat.sh/unknown".to_string(), + &HashMap::from([("user-agent".to_string(), "fetch".to_string())]), + ); + assert!(output.is_err()); + assert_eq!(output.expect_err("Unreachable").to_string(), + "External help provider error: `Unknown topic, This topic/command might has no page in this provider yet.`"); + } +} diff --git a/src/helper/docs/handlers/file.rs b/src/helper/docs/handlers/file.rs new file mode 100644 index 0000000..454e890 --- /dev/null +++ b/src/helper/docs/handlers/file.rs @@ -0,0 +1,23 @@ +use crate::error::Result; +use crate::helper::docs::handlers::Handler; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// The file read operation handler. +pub struct FileHandler; + +impl Handler for FileHandler { + /// Just read the file and return its content. + /// + /// This operation handler does not support any arguments. i can't think of any useful arguments for this operation :P + fn handle(&self, path: String, _: &HashMap) -> Result> { + let path = Path::new(&path); + if path.exists() { + let content = fs::read_to_string(path)?; + Ok(Some(content)) + } else { + Ok(None) + } + } +} diff --git a/src/helper/docs/handlers/mod.rs b/src/helper/docs/handlers/mod.rs new file mode 100644 index 0000000..48d4050 --- /dev/null +++ b/src/helper/docs/handlers/mod.rs @@ -0,0 +1,15 @@ +/// The command operation handler. +pub mod command; +/// The fetch operation handler. +pub mod fetch; +/// The file read operation handler. +pub mod file; + +use crate::error::Result; +use std::collections::HashMap; + +/// The operation handler trait. +pub trait Handler { + /// Handles the operation. + fn handle(&self, _: String, args_map: &HashMap) -> Result>; +} diff --git a/src/helper/docs/man.rs b/src/helper/docs/man.rs deleted file mode 100644 index 98de81f..0000000 --- a/src/helper/docs/man.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::error::Result; -use std::process::Command; - -/// Runs the manual page command. -pub fn show_man_page(man_cmd: &str, cmd: &str) -> Result<()> { - let command = format!("{} {}", man_cmd, cmd); - let mut process = if cfg!(target_os = "windows") { - Command::new("cmd").args(["/C", &command]).spawn() - } else { - Command::new("sh").args(["-c", &command]).spawn() - }?; - process.wait()?; - Ok(()) -} diff --git a/src/helper/docs/mod.rs b/src/helper/docs/mod.rs index 50a9b2d..42c0210 100644 --- a/src/helper/docs/mod.rs +++ b/src/helper/docs/mod.rs @@ -1,173 +1,125 @@ -/// Man page helper. -pub mod man; - -/// Cheat sheet helper. -pub mod cheat_sh; -/// cheat helper. -pub mod cheatsheets; -/// eg page helper. -pub mod eg; +use std::collections::HashMap; +use std::io::Write; +use std::process::{Command, Stdio}; -use crate::config::Config; -use crate::error::{Error, Result}; -use crate::helper::docs::cheat_sh::CheatDotSh; -use crate::helper::docs::cheatsheets::Cheatsheets; -use crate::helper::docs::eg::Eg; -use crate::helper::docs::man::show_man_page; +use colored::Colorize; use console::{style, Style, Term}; use dialoguer::theme::ColorfulTheme; use dialoguer::Select; -use std::io::Write; -use std::process::{Command, Stdio}; -use ureq::Request; - -/// The `HelpProvider` trait defines essential methods for fetching help content related to commands from a provider. -/// -/// Each provider that implements this trait should provide a default URL used to retrieve the command help content. -/// However, it also allows specifying a custom URL to override the default one. -/// -/// This trait is generic and not tied to any specific command help system such as man pages or POSIX documentation, -/// instead it relies on the implementation to define how to fetch and mark up the content. -/// -/// # Methods -/// -/// - `url`: Returns the default URL of the provider. -/// - `build_req`: Uses the given command and URL to create an HTTP GET request. -/// - `err_handle`: Handles possible request errors, such as a non-existent command page on the provider. -/// - `fetch`: Attempts to retrieve the command page from the provider, optionally from a custom URL. -/// -/// # Example -/// -/// An implementation could be created for a provider that supplies help pages in Markdown format. -/// The `url` method would return the base URL of this provider. -/// The `build_request` method could construct a GET request for `{base_url}/{command}.md`. -/// The `handle_error` could interpret a 404 status code as 'Command page not found'. -/// The `fetch` would handle fetching the specified command page using the constructed request. -pub trait HelpProvider { - /// Return the default provider URL. - fn url(&self) -> &'static str; - - /// Builds an HTTP request using the given `cmd` and `url`. - /// - /// # Parameters - /// - /// - `cmd`: The name of the command to be included in the request. - /// - `url`: The root URL. - /// - /// # Returns - /// This method returns a new `Request` instance configured with the `GET` method and the formatted URL. - fn build_request(&self, cmd: &str, url: &str) -> Request; - - /// Handle the request error. - /// aka return a custom message if the error means that **provider** doesn't have a page for the command. - fn handle_error(&self, e: ureq::Error) -> Error { - if e.kind() == ureq::ErrorKind::HTTP { - Error::ProviderError( - "Unknown topic, This topic/command might has no page in this provider yet." - .to_string(), - ) - } else { - Error::from(Box::new(e)) - } - } - - /// **The default** fetch implementation. - /// - /// This method attempts to retrieve the specified command page from the given provider. - /// If a `custom_url` is provided, this URL is used instead of the default URL. - /// The method will return the content of the command page if the fetch operation is successful. - #[inline(always)] - fn _fetch(&self, cmd: &str, custom_url: &Option) -> Result { - let url = { - if let Some(u) = custom_url { - u.as_str() - } else { - self.url() - } - }; - let response = self.build_request(cmd, url).call(); - let response = response.map_err(|e| self.handle_error(e)); - - match response { - Ok(response) => Ok(response.into_string()?), - Err(e) => Err(e), - } - } +use crate::config::{plz_menu::PlzMenuSelection, Config}; +use crate::error::{Error, Result}; +use crate::helper::args::FAIL_EMOTICON; +use crate::helper::docs::cmd_parse::parse_cmd; +use crate::helper::docs::handlers::{command::CommandHandler, fetch::FetchHandler, Handler}; +use crate::helper::docs::template::parse_template; - /// Fetches the command page from the provider. - /// - /// # Parameters - /// - /// - `cmd`: The name of the command for which the page should be fetched. - /// - `custom_url`: Optional parameter that, if supplied, specifies a custom URL from which to fetch the command page. - /// - /// # Returns - /// - /// This method returns a Result type. On successful fetch, it contains a `String` with the content of the fetched page. - /// In case of failure, it contains an error that provides further details about the issue encountered during the fetch operation. - /// - /// # Errors - /// - /// This method will return an error if the fetch operation fails. - fn fetch(&self, cmd: &str, custom_url: &Option) -> Result { - self._fetch(cmd, custom_url) - } -} +/// Command parser. +pub mod cmd_parse; +/// Plz menu operation handlers. +pub mod handlers; +/// Plz menu template parser. +pub mod template; /// Shows documentation/usage help about the given command. -pub fn get_docs_help(cmd: &str, config: &Config, output: &mut Output) -> Result<()> { - const MAN_PAGE: usize = 0; - const CHEAT_SHEET: usize = 1; - const EG_PAGE: usize = 2; - const CHEATSHEETS: usize = 3; - let menu_options = [ - "Show man page", - "Show cheat.sh page", - "Show the eg page", - "Show the cheatsheet page", - "Exit", - ]; - let mut selection = Some(MAN_PAGE); +pub fn get_docs_help(cmd: &str, config: Config, output: &mut Output) -> Result<()> { + let mut menu_options = config + .plz_menu + .entries + .iter() + .map(|e| e.display_msg.as_str()) + .collect::>(); + use PlzMenuSelection as Selection; + let mut selection = match config.plz_menu.selected_pos { + Selection::Start => Some(0), + Selection::Center => Some(menu_options.len() / 2), + Selection::End => Some(menu_options.len() - 1), + }; + menu_options.push("Exit"); + let values_map = build_the_values_map(cmd); loop { selection = Select::with_theme(&get_selection_theme()) .with_prompt("Select operation") .default(selection.unwrap_or_default()) .items(&menu_options) .interact_on_opt(&Term::stderr())?; - if let Some(MAN_PAGE) = selection { - show_man_page(&config.man_command, cmd)? - } else { - let page = match selection { - Some(CHEAT_SHEET) => CheatDotSh.fetch(cmd, &config.cheat_sh_url)?, - Some(EG_PAGE) => Eg.fetch(cmd, &config.eg_url)?, - Some(CHEATSHEETS) => Cheatsheets.fetch(cmd, &config.cheatsheets_url)?, - _ => return Ok(()), - }; - // Show the page using the user selected pager or write it directly into the output - if let Some(pager) = config.pager_command.as_ref() { - let mut process = if cfg!(target_os = "windows") { - Command::new("cmd") - .args(["/C", pager]) - .stdin(Stdio::piped()) - .spawn() - } else { - Command::new("sh") - .args(["-c", pager]) - .stdin(Stdio::piped()) - .spawn() - }?; - if let Some(stdin) = process.stdin.as_mut() { - writeln!(stdin, "{}", page)?; - process.wait()?; - } + // Exit conditions + let Some(selection) = selection else { break Ok(()); }; + let Some(entry) = config.plz_menu.entries.get(selection) else { break Ok(()); }; + let operation_iter = entry.operation.iter(); + // if there is no key, then return an error, the operation key is required. + let (mut op_key, mut op_value) = (None, None); + // Create a new map with the capacity of the values map minus the first entry, to contain the parsed values. + let mut parsed_map = HashMap::with_capacity(values_map.len() - 1); + for (key, value) in operation_iter { + // If the operation key is not set, then check if the key is a valid operation key. + if op_key.is_none() + && (key == "fetch" + || key == "url" + || key == "command" + || key == "run" + || key == "file") + { + op_key = Some(key.clone()); + op_value = Some(value.clone()); + continue; + } + parsed_map.insert(key.as_str(), parse_template(value, &values_map)?); + } + // If the operation key is not set, then return an error, the operation key is required. + let (Some(op_key), Some(op_value)) = (op_key, op_value) else {return Err(Error::PlzMenuNoOperation)}; + let op_value = parse_template(&op_value, &values_map)?; + let result = match op_key.as_str() { + "fetch" | "url" => FetchHandler.handle(op_value, &entry.operation), + "command" | "run" => CommandHandler.handle(op_value, &entry.operation), + "file" => FetchHandler.handle(op_value, &entry.operation), + _ => break Err(Error::PlzMenuInvalidOperation(op_key.to_string())), + }; + let Ok(result) = result else { + writeln!(output, "{} {}", FAIL_EMOTICON.magenta(), + result.expect_err("error").to_string().red().bold())?; + continue; + }; + let Some(page) = result else { continue; }; + // Show the page using the user selected pager or write it directly into the output + if let Some(pager) = config.pager_command.as_ref() { + let mut process = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", pager]) + .stdin(Stdio::piped()) + .spawn() } else { - writeln!(output, "{}", page)?; + Command::new("sh") + .args(["-c", pager]) + .stdin(Stdio::piped()) + .spawn() + }?; + if let Some(stdin) = process.stdin.as_mut() { + writeln!(stdin, "{}", page)?; + process.wait()?; } + } else { + writeln!(output, "{}", page)?; } } } +/// Builds the values map for the template engine. +#[inline(always)] +fn build_the_values_map(cmd: &str) -> HashMap { + let mut map = HashMap::with_capacity(5); + parse_cmd(cmd, &mut map); // This will add at least one entry and at most 3 entries + if map.capacity() < 3 { + map.shrink_to(map.capacity() + 2); + } + map.insert( + "halp-version".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ); + map.insert("os".to_string(), std::env::consts::OS.to_string()); + map +} + /// Returns the theme for selection prompt. fn get_selection_theme() -> ColorfulTheme { ColorfulTheme { diff --git a/src/helper/docs/template.rs b/src/helper/docs/template.rs new file mode 100644 index 0000000..0e3a9fe --- /dev/null +++ b/src/helper/docs/template.rs @@ -0,0 +1,254 @@ +use std::collections::HashMap; + +use thiserror::Error as ThisError; + +const OPENING_BRACE: char = '{'; +const CLOSING_BRACE: char = '}'; +const ESCAPE_CHAR: char = '\\'; +const OPTIONAL_CHAR: char = '?'; + +/// The template error type. +#[derive(Debug, ThisError, PartialEq)] +pub enum HalpTemplateError { + /// Error that might occur if there an opening bract and no closing bract for it. + #[error("Missing closing bract at index: `{0}`")] + MissingClosingBract(usize), + /// Error that might occur if there an closing bract and no opening bract for it. + #[error("Missing opening bract at index: `{0}`")] + MissingOpeningBract(usize), + /// Error that might occur if there an opening bracts but there is a placeholder name in + #[error("Missing placeholder at index: `{0}`")] + MissingPlaceholder(usize), + /// Error that might occur if there is no placeholder with the given name. + #[error("No such placeholder with name: `{0}`")] + NoSuhPlaceholder(String), +} + +/// Type alias for Template Result. +pub type Result = std::result::Result; + +/// Parses the template string and replaces the correct values from the values map. +/// +/// # Arguments +/// - `template`: The template string to parse. +/// - `values_map`: The values map to replace the values from. +/// +/// # Returns +/// This method returns a Result type. On successful, it contains a `String` with the parsed template. +/// +/// # Example +/// ``` +/// # use halp::helper::docs::template::parse_template; +/// # use std::collections::HashMap; +/// let template = "Hello {name}!"; +/// let mut values_map = HashMap::new(); +/// values_map.insert("name".to_string(), "Ferris".to_string()); +/// let parsed = parse_template(template, &values_map); +/// assert_eq!(parsed, Ok("Hello Ferris!".to_string())); +/// ``` +/// +/// # Errors +/// - [`HalpTemplateError::MissingClosingBract`]: If there an opening bract and no closing bract for it. +/// - [`HalpTemplateError::MissingOpeningBract`]: If there an closing bract and no opening bract for it. +/// - [`HalpTemplateError::MissingPlaceholder`]: If there an opening bracts but there is a placeholder name in. +/// - [`HalpTemplateError::NoSuhPlaceholder`]: If there is no key in the values map with the given name. +pub fn parse_template(template: &str, values_map: &HashMap) -> Result { + const BUFFER_CAPACITY: usize = 13; + let mut processed_string = String::with_capacity(template.len() + BUFFER_CAPACITY); + let mut buffer = String::with_capacity(BUFFER_CAPACITY); + let mut op_buffer = String::with_capacity(BUFFER_CAPACITY); + let mut optional = false; + let mut iter = template.chars(); + let mut nested_level = 0u8; + loop { + let Some(c) = iter.next() else { break; }; + match c { + ESCAPE_CHAR => { + let Some(next) = iter.next() else { + buffer.push(c); + continue; + }; + if next == OPENING_BRACE || next == CLOSING_BRACE || next == OPTIONAL_CHAR { + buffer.push(next); + } + } + OPENING_BRACE => { + nested_level += 1; + if optional { + op_buffer.push_str(&buffer); + } else { + processed_string.push_str(&buffer); + } + buffer.clear() + } + CLOSING_BRACE => { + let key = buffer.drain(..).collect::(); + if key.is_empty() { + if nested_level == 0 { + return Err(HalpTemplateError::MissingOpeningBract( + processed_string.len() + buffer.len(), + )); + } + nested_level -= 1; + continue; + } + if let Some(val) = values_map.get(&key) { + processed_string.push_str(&op_buffer); + processed_string.push_str(val); + optional = false; + } else if !optional { + return Err(HalpTemplateError::NoSuhPlaceholder(key)); + } + op_buffer.clear(); + nested_level -= 1; + } + OPTIONAL_CHAR => { + optional = true; + } + _ => buffer.push(c), + } + } + if !buffer.is_empty() { + processed_string += &buffer; + } + if nested_level != 0 { + return Err(HalpTemplateError::MissingClosingBract( + processed_string.len(), + )); + } + Ok(processed_string) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + const CHEAT_SH_URL_TEMPLATE: &str = "https://cheat.sh/{cmd}{?/{subcommand}}{? {args}}"; + + fn values_map() -> HashMap { + let mut values_map = HashMap::new(); + values_map.insert("cmd".to_string(), "git".to_string()); + values_map.insert("subcommand".to_string(), "commit".to_string()); + values_map.insert("args".to_string(), "-a".to_string()); + values_map + } + + #[test] + fn test_parse_cheat_dot_sh_template() -> Result<()> { + let result = parse_template(CHEAT_SH_URL_TEMPLATE, &values_map())?; + assert_eq!(result, "https://cheat.sh/git/commit -a"); + Ok(()) + } + + #[test] + fn test_parse_cheat_dot_sh_template_no_args() -> Result<()> { + let mut values_map = values_map(); + values_map.remove("args"); + let result = parse_template(CHEAT_SH_URL_TEMPLATE, &values_map)?; + assert_eq!(result, "https://cheat.sh/git/commit"); + Ok(()) + } + + #[test] + fn test_dose_nothing() -> Result<()> { + let result = parse_template("https://cheat.sh/git/commit -a", &values_map())?; + assert_eq!(result, "https://cheat.sh/git/commit -a"); + Ok(()) + } + + #[test] + fn test_basic_template_with_one_placeholder() -> Result<()> { + let result = parse_template("{cmd}", &values_map())?; + assert_eq!(result, "git"); + Ok(()) + } + + #[test] + fn test_basic_template_with_one_placeholder_and_text() -> Result<()> { + let result = parse_template("man {cmd}", &values_map())?; + assert_eq!(result, "man git"); + Ok(()) + } + + #[test] + fn test_basic_template_with_more_than_one_placeholder() -> Result<()> { + let result = parse_template("{cmd} {subcommand}", &values_map())?; + assert_eq!(result, "git commit"); + Ok(()) + } + + #[test] + fn test_basic_template_with_more_then_one_placeholder_and_text() -> Result<()> { + let result = parse_template("info {cmd}/{subcommand}", &values_map())?; + assert_eq!(result, "info git/commit"); + Ok(()) + } + + #[test] + fn test_nested_options_lv_one() -> Result<()> { + let result = parse_template("{cmd}{?/{subcommand}}", &values_map())?; + assert_eq!(result, "git/commit"); + Ok(()) + } + + #[test] + fn test_nested_options_lv_two() -> Result<()> { + let result = parse_template("{cmd}{?/{subcommand}{? {args}}}", &values_map())?; + assert_eq!(result, "git/commit -a"); + Ok(()) + } + + #[test] + fn test_nested_options_lv_three() -> Result<()> { + let result = parse_template("{cmd}{?/{subcommand}{? {args}{? {args2}}}}", &values_map())?; + assert_eq!(result, "git/commit -a"); + Ok(()) + } + + #[test] + fn test_nested_options_lv_three_2() -> Result<()> { + let result = parse_template("{cmd}{?/{subcommand}{?/{args}{?/{args2}}}}", &values_map())?; + assert_eq!(result, "git/commit/-a"); + Ok(()) + } + + #[test] + fn test_escape() -> Result<()> { + let result = parse_template("\\{cmd\\}", &values_map())?; + assert_eq!(result, "{cmd}"); + Ok(()) + } + + #[test] + fn test_escape_with_placeholder() -> Result<()> { + let result = parse_template("\\{cmd\\} {subcommand} \\", &values_map())?; + assert_eq!(result, "{cmd} commit \\"); + Ok(()) + } + + #[test] + fn test_escape_with_placeholder_and_option() -> Result<()> { + let result = parse_template("\\{cmd\\}{?/{subcommand}} {?args}", &values_map())?; + assert_eq!(result, "{cmd}/commit -a"); + Ok(()) + } + + #[test] + fn test_none_exist_placeholder_error() { + let result = parse_template("{cmd}-{subcommand} {argsn}", &values_map()); + assert!(result.is_err()); + assert_eq!( + result.expect_err("This should fail"), + HalpTemplateError::NoSuhPlaceholder("argsn".to_string()) + ); + } + + #[test] + fn test_no_placeholder_error_handle() -> Result<()> { + let result = parse_template("search: {cmd}-{?subcommand} {?argsn}", &values_map())?; + assert_eq!(result, "search: git-commit "); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index dc919c8..89f4316 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,11 +15,11 @@ pub mod error; pub mod config; use crate::cli::CliArgs; +use crate::config::Config; use crate::error::Result; use crate::helper::args::FAIL_EMOTICON; use cli::CliCommands; use colored::*; -use config::Config; use helper::args::get_args_help; use helper::docs::get_docs_help; use std::io::Write; @@ -46,9 +46,9 @@ pub fn run(cli_args: CliArgs, output: &mut Output) -> Result<()> }; cli_args.update_config(&mut config); if let Some(ref cmd) = cli_args.cmd { - get_args_help(cmd, &config, cli_args.verbose, output)?; + get_args_help(cmd, config, cli_args.verbose, output)?; } else if let Some(CliCommands::Plz { ref cmd, .. }) = cli_args.subcommand { - get_docs_help(cmd, &config, output)?; + get_docs_help(cmd, config, output)?; } Ok(()) }