diff --git a/Cargo.lock b/Cargo.lock index e7abb4e..4d3909c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -102,6 +102,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f8cb5d814eb646a863c4f24978cff2880c4be96ad8cde2c0f0678732902e271" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "assert_cmd" version = "2.0.17" @@ -818,7 +828,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hit-cli" -version = "0.5.1" +version = "0.6.0" dependencies = [ "arboard", "array_tool", @@ -839,6 +849,7 @@ dependencies = [ "hyper", "inquire", "insta", + "mockito", "openapiv3", "predicates", "regex", @@ -901,6 +912,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "human-panic" version = "2.0.2" @@ -930,6 +947,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1303,6 +1321,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -1336,6 +1364,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1642,6 +1695,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.3" @@ -1705,6 +1767,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -1783,6 +1874,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -2483,6 +2575,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -3041,6 +3139,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 0ca395e..ec05520 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hit-cli" -version = "0.5.1" +version = "0.6.0" edition = "2021" authors = ["Mehmood S. Deshmukh "] homepage = "https://usehit.dev" @@ -29,7 +29,7 @@ hyper = "1.3.1" inquire = "0.7.5" openapiv3 = "1.0.1" regex = "1.10.4" -reqwest = {version="0.12.3", features=["json"]} +reqwest = {version="0.12.3", features=["json", "multipart"]} serde = {version="1.0.200", features=["derive"]} serde_json = "1.0" serde_yaml = "0.9" @@ -40,5 +40,6 @@ tokio = {version = "1.37.0", features = ["full"]} [dev-dependencies] assert_cmd = "2.0.17" insta = {version="1.43.1", features=["json"]} +mockito = "1.7" predicates = "3.1.3" rstest = "0.25.0" diff --git a/README.md b/README.md index 1f19aeb..5c3938d 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,135 @@ hit run users get --user-id 47 ``` +### Request Body + +Commands that use `POST`, `PUT`, or `PATCH` methods can include a request body in the config. Parameters in the body work the same way as route params — prefix them with `:` and pass values as command-line options. + +```json +{ + "commands": { + "create-user": { + "url": "{{API_URL}}/users", + "method": "POST", + "body": { + "name": ":name", + "email": ":email" + } + } + } +} +``` + +```bash +hit run create-user --name "Jane Doe" --email "jane@example.com" +``` + +#### Editing the Body Before Sending + +If you want to review or modify the request body in your system editor before sending, use the `--edit-body` (or `-e`) flag: + +```bash +hit run create-user --name "Jane Doe" --email "jane@example.com" --edit-body +``` + +#### Providing the Body from a File + +Instead of using the body defined in the config, you can provide a body from a file using `--body-file`: + +```bash +hit run create-user --body-file payload.json +``` + +The file contents support environment variable substitution (double curly brace syntax) just like the config body. + +### Typed Parameters + +By default, all parameter values are substituted as strings. For JSON request bodies, you may need values to be a specific JSON type. `hit` supports type annotations on parameters using the `|type` suffix. + +#### Boolean Parameters + +Use `|boolean` to substitute the value as a JSON boolean (`true`/`false`) instead of a string: + +```json +{ + "commands": { + "update-setting": { + "url": "{{API_URL}}/settings", + "method": "POST", + "body": { + "dryRun": ":dryRun|boolean", + "name": ":name" + } + } + } +} +``` + +```bash +hit run update-setting --dry-run true --name "test" +# Sends: {"dryRun": true, "name": "test"} +``` + +#### Number Parameters + +Use `|number` to substitute the value as a JSON number: + +```json +{ + "commands": { + "list-items": { + "url": "{{API_URL}}/items", + "method": "POST", + "body": { + "limit": ":limit|number" + } + } + } +} +``` + +```bash +hit run list-items --limit 42 +# Sends: {"limit": 42} +``` + +#### File Parameters + +Use `|file` to upload a file as part of a multipart form request. When a file parameter is present, the request is automatically sent as `multipart/form-data`: + +```json +{ + "commands": { + "upload": { + "url": "{{API_URL}}/upload", + "method": "POST", + "body": { + "document": ":filePath|file", + "description": ":description" + } + } + } +} +``` + +```bash +hit run upload --file-path ./report.pdf --description "Monthly report" +``` + +### JSON Output + +By default, `hit` pretty-prints the response body with syntax highlighting. For scripted or non-interactive usage, the `--json` flag outputs the full response (URL, status code, headers, and body) as a single JSON object: + +```bash +hit run list-users --json +``` + +This is useful for piping into `jq` or other tools: + +```bash +hit run list-users --json | jq '.body | fromjson | .[] | .name' +``` + ### Inspecting the response of an API call Normally, running a command would simply output the body of the response of the API call being made. If you would like to inspect the entire response including the status code and response headers, this can be done by running the command: diff --git a/src/cli/env/use.rs b/src/cli/env/use.rs index edb709e..8620ef6 100644 --- a/src/cli/env/use.rs +++ b/src/cli/env/use.rs @@ -7,5 +7,6 @@ pub struct EnvUseArguments { } pub fn init(args: EnvUseArguments) -> Result<(), Box> { - Ok(set_env(args.env)) + set_env(args.env); + Ok(()) } diff --git a/src/cli/ephenv/set.rs b/src/cli/ephenv/set.rs index 7f3fad6..eac7463 100644 --- a/src/cli/ephenv/set.rs +++ b/src/cli/ephenv/set.rs @@ -8,5 +8,6 @@ pub struct EphenvSetArguments { } pub fn init(args: EphenvSetArguments) -> Result<(), Box> { - Ok(set_ephenv(args.key, args.value)) + set_ephenv(args.key, args.value); + Ok(()) } diff --git a/src/cli/last/view.rs b/src/cli/last/view.rs index 0515963..586fe93 100644 --- a/src/cli/last/view.rs +++ b/src/cli/last/view.rs @@ -1,7 +1,6 @@ use crate::core::app_config::get_app_config; use crate::utils::input::CustomAutocomplete; use arboard::Clipboard; -use colored_json; use crossterm::event::{read, Event, KeyCode}; use crossterm::terminal; use flatten_json_object::Flattener; @@ -10,7 +9,7 @@ use serde_json::Value; use std::io::stdout; use std::io::Write; -fn get_json_value_from_path<'a, 'b>(json: &'a Value, path: &'b str) -> Option<&'a Value> { +fn get_json_value_from_path<'a>(json: &'a Value, path: &str) -> Option<&'a Value> { json.pointer(format!("/{}", path.replace(".", "/")).as_str()) } @@ -21,48 +20,46 @@ pub fn init() -> Result<(), Box> { .clone(); let mut prev_request = serde_json::to_value(prev_request).unwrap(); - if let Ok(body_json) = serde_json::from_str::(&prev_request["body"].as_str().unwrap()) { + if let Ok(body_json) = serde_json::from_str::(prev_request["body"].as_str().unwrap()) { prev_request["body"] = body_json; } let mut out = stdout(); colored_json::write_colored_json(&prev_request, &mut out).unwrap(); out.flush().unwrap(); - writeln!(out, "").unwrap(); + writeln!(out).unwrap(); println!("Press c to enter copy mode or any other key to exit"); terminal::enable_raw_mode().unwrap(); loop { - if let Ok(event) = read() { - if let Event::Key(key) = event { - terminal::disable_raw_mode().unwrap(); - if key.code == KeyCode::Char('c') { - let flattened_json = Flattener::new().flatten(&prev_request).unwrap(); + if let Ok(Event::Key(key)) = read() { + terminal::disable_raw_mode().unwrap(); + if key.code == KeyCode::Char('c') { + let flattened_json = Flattener::new().flatten(&prev_request).unwrap(); - let json_paths: Vec = flattened_json - .as_object() - .unwrap() - .keys() - .map(|k| k.to_string()) - .collect(); + let json_paths: Vec = flattened_json + .as_object() + .unwrap() + .keys() + .map(|k| k.to_string()) + .collect(); - let user_json_path = Text::new("Enter the JSON path: ") - .with_autocomplete(CustomAutocomplete::new(json_paths)) - .prompt() - .unwrap(); - Clipboard::new() - .unwrap() - .set_text( - get_json_value_from_path(&prev_request, &user_json_path) - .unwrap() - .to_string(), - ) - .unwrap(); - } - break; + let user_json_path = Text::new("Enter the JSON path: ") + .with_autocomplete(CustomAutocomplete::new(json_paths)) + .prompt() + .unwrap(); + Clipboard::new() + .unwrap() + .set_text( + get_json_value_from_path(&prev_request, &user_json_path) + .unwrap() + .to_string(), + ) + .unwrap(); } + break; } } - println!(""); + println!(); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c11bdd7..f1eea86 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,12 +7,18 @@ mod run; use crate::core::command::Command as ConfigCommand; use crate::core::config::{CommandType as ConfigCommandType, Config}; use crate::utils::error::CliError; -use clap::{command, Arg, ArgMatches, Command, FromArgMatches as _, Parser, Subcommand}; +use clap::{Arg, ArgAction, ArgMatches, Command, FromArgMatches as _, Parser, Subcommand}; use clap_complete::CompleteEnv; use convert_case::{Case, Casing}; use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; use std::process::ExitCode; +const GLOBAL_ARG_EDIT_BODY: &str = "edit-body"; +const GLOBAL_ARG_BODY_FILE: &str = "body-file"; +const GLOBAL_ARG_JSON: &str = "json"; + #[derive(Debug, Parser)] #[command(version)] enum StaticCommand { @@ -38,7 +44,7 @@ fn formulate_command( for param in params { subcommand = subcommand.arg( Arg::new(param.to_string()) - .long(¶m.to_string().to_case(Case::Kebab)) + .long(param.to_string().to_case(Case::Kebab)) .value_name(param.to_string()) .help(format!("Provide value for the param :{}", param)), ) @@ -56,36 +62,66 @@ fn formulate_command( command.clone() } -fn obtain_run_command_from_matches( - matches: &ArgMatches, +fn obtain_run_command_from_matches<'a>( + matches: &'a ArgMatches, config_commands: &HashMap>, args_map: &mut HashMap, -) -> ConfigCommand { +) -> (ConfigCommand, &'a ArgMatches) { + let global_args: HashSet<&str> = [GLOBAL_ARG_EDIT_BODY, GLOBAL_ARG_BODY_FILE, GLOBAL_ARG_JSON] + .into_iter() + .collect(); + let subcommand_name = matches.subcommand_name().unwrap(); let config_command_value = config_commands.get(subcommand_name).unwrap(); - let subcommand_matches = matches.subcommand_matches(&subcommand_name).unwrap(); + let subcommand_matches = matches.subcommand_matches(subcommand_name).unwrap(); match **config_command_value { ConfigCommandType::Command(ref config_command) => { for arg_id in subcommand_matches.ids() { + let id_str = arg_id.as_str(); + if global_args.contains(id_str) { + continue; + } args_map.insert( - arg_id.to_string(), + id_str.to_string(), subcommand_matches - .get_one::(arg_id.as_str()) + .get_one::(id_str) .unwrap() .to_string(), ); } - config_command.clone() + (config_command.clone(), subcommand_matches) } ConfigCommandType::NestedCommand(ref config_command) => { - obtain_run_command_from_matches(&subcommand_matches, &config_command, args_map) + obtain_run_command_from_matches(subcommand_matches, config_command, args_map) } } } fn get_run_command(config: &Config) -> Command { - let mut command = Command::new("run").arg_required_else_help(true); + let mut command = Command::new("run") + .arg_required_else_help(true) + .arg( + Arg::new(GLOBAL_ARG_EDIT_BODY) + .long("edit-body") + .short('e') + .action(ArgAction::SetTrue) + .global(true) + .help("Open editor to review/edit request body before sending"), + ) + .arg( + Arg::new(GLOBAL_ARG_BODY_FILE) + .long("body-file") + .global(true) + .help("Read request body from a file instead of using the configured body"), + ) + .arg( + Arg::new(GLOBAL_ARG_JSON) + .long("json") + .action(ArgAction::SetTrue) + .global(true) + .help("Output response as structured JSON (url, status, headers, body)"), + ); command = formulate_command(command, &config.commands); command @@ -110,12 +146,21 @@ pub async fn init() -> ExitCode { let mut args_map = HashMap::new(); - let config_command = obtain_run_command_from_matches( - &run_subcommand_matches, + let (config_command, leaf_matches) = obtain_run_command_from_matches( + run_subcommand_matches, &config.commands, &mut args_map, ); - run::run(&config_command, args_map).await + + let options = run::RunOptions { + edit_body: leaf_matches.get_flag(GLOBAL_ARG_EDIT_BODY), + body_file: leaf_matches + .get_one::(GLOBAL_ARG_BODY_FILE) + .map(PathBuf::from), + json_output: leaf_matches.get_flag(GLOBAL_ARG_JSON), + }; + + run::run(&config_command, args_map, options).await } _ => { let static_command_matches = StaticCommand::from_arg_matches(&matches).unwrap(); diff --git a/src/cli/run.rs b/src/cli/run.rs index 838cfc6..6b7f59f 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -5,7 +5,6 @@ use crate::core::env::get_env; use crate::core::ephenv::get_ephenvs; use crate::utils::error::CliError; use crate::utils::http::handle_request; -use colored_json; use edit::edit; use handlebars::Handlebars; use regex::Regex; @@ -14,17 +13,139 @@ use std::collections::HashMap; use std::error::Error; use std::io::stdout; use std::io::Write; +use std::path::PathBuf; + +const BREAKING_CHANGE_VERSION: &str = "0.6.0"; + +fn check_upgrade_notice() -> bool { + let mut app_config = get_app_config(); + let needs_notice = match app_config.get_last_seen_version() { + Some(version) => version.as_str() < BREAKING_CHANGE_VERSION, + None => true, + }; + + if needs_notice { + eprintln!( + "NOTE: As of v{}, the editor no longer opens automatically for request bodies.", + BREAKING_CHANGE_VERSION + ); + eprintln!("Use --edit-body (-e) to review/edit the body before sending."); + eprintln!("Please re-run your command to continue."); + app_config.set_last_seen_version(env!("CARGO_PKG_VERSION").to_string()); + } + + needs_notice +} fn replace_params(input: String, params: &HashMap) -> String { params.keys().fold(input, |acc, x| { - acc.replace(&format!(":{}", x), ¶ms.get(x).unwrap()) + acc.replace(&format!(":{}", x), params.get(x).unwrap()) + }) +} + +pub struct RunOptions { + pub edit_body: bool, + pub body_file: Option, + pub json_output: bool, +} + +struct TypedSubstitutionResult { + body: String, + file_fields: HashMap, +} + +fn replace_params_typed( + input: String, + params: &HashMap, + param_types: &HashMap>, +) -> Result> { + let mut result = input.clone(); + let mut file_fields: HashMap = HashMap::new(); + + for (param_name, param_value) in params { + let param_type = param_types + .get(param_name.as_str()) + .and_then(|t| t.as_deref()); + + match param_type { + Some("boolean") => { + if param_value != "true" && param_value != "false" { + return Err(Box::new(CliError { + message: format!( + "Parameter '{}' expects a boolean value (true/false), got '{}'", + param_name, param_value + ), + })); + } + let quoted_placeholder = format!("\":{}|boolean\"", param_name); + result = result.replace("ed_placeholder, param_value); + } + Some("number") => { + if param_value.parse::().is_err() { + return Err(Box::new(CliError { + message: format!( + "Parameter '{}' expects a number value, got '{}'", + param_name, param_value + ), + })); + } + let quoted_placeholder = format!("\":{}|number\"", param_name); + result = result.replace("ed_placeholder, param_value); + } + Some("file") => { + let path = PathBuf::from(param_value); + if !path.exists() { + return Err(Box::new(CliError { + message: format!( + "File not found for parameter '{}': {}", + param_name, param_value + ), + })); + } + + let placeholder = format!(":{}|file", param_name); + if let Ok(mut json_val) = serde_json::from_str::(&result) { + if let Some(obj) = json_val.as_object_mut() { + let mut file_key = None; + for (key, val) in obj.iter() { + if let Some(s) = val.as_str() { + if s == placeholder { + file_key = Some(key.clone()); + break; + } + } + } + if let Some(key) = file_key { + let key_clone = key.clone(); + obj.remove(&key); + result = serde_json::to_string(&json_val).unwrap(); + file_fields.insert(key_clone, path); + } + } + } + } + _ => { + let placeholder = format!(":{}", param_name); + result = result.replace(&placeholder, param_value); + } + } + } + + Ok(TypedSubstitutionResult { + body: result, + file_fields, }) } pub async fn run( api_call: &Command, param_values: HashMap, + options: RunOptions, ) -> Result<(), Box> { + if check_upgrade_notice() { + return Ok(()); + } + let config = Config::new(); let env_var_regex = Regex::new(r"\{\{\w+}}").unwrap(); @@ -37,7 +158,6 @@ pub async fn run( None => { return Err(Box::new(CliError { message: "env not set".to_string(), - help: None, })) } }; @@ -46,7 +166,6 @@ pub async fn run( None => { return Err(Box::new(CliError { message: "env not recognized".to_string(), - help: None, })) } }; @@ -65,21 +184,46 @@ pub async fn run( let url_to_call = replace_params(url_with_env_vars, ¶m_values); - let input = if api_call.body.is_some() { - Some( - edit(replace_params( - hb_handle - .render_template( - &serde_json::to_string_pretty(&api_call.body).unwrap(), - &merged_data, - ) - .unwrap(), - ¶m_values, - )) - .expect("Unable to open system editor"), - ) + let (input, file_fields) = if let Some(ref body_file_path) = options.body_file { + let file_content = std::fs::read_to_string(body_file_path).map_err(|e| { + Box::new(CliError { + message: format!( + "Failed to read body file '{}': {}", + body_file_path.display(), + e + ), + }) + })?; + let rendered = if env_var_regex.is_match(&file_content) { + hb_handle.render_template(&file_content, &merged_data)? + } else { + file_content + }; + (Some(rendered), None) + } else if api_call.body.is_some() { + let serialized = serde_json::to_string_pretty(&api_call.body).unwrap(); + let rendered = hb_handle + .render_template(&serialized, &merged_data) + .unwrap(); + + let param_types = api_call.body_param_types(); + let typed_result = replace_params_typed(rendered, ¶m_values, ¶m_types)?; + + let body_str = if options.edit_body { + edit(&typed_result.body).expect("Unable to open system editor") + } else { + typed_result.body + }; + + let file_fields = if typed_result.file_fields.is_empty() { + None + } else { + Some(typed_result.file_fields) + }; + + (Some(body_str), file_fields) } else { - None + (None, None) }; let response = handle_request( @@ -92,34 +236,39 @@ pub async fn run( .map(|(k, v)| (k, hb_handle.render_template(&v, &merged_data).unwrap())) .collect::>(), input, + file_fields, ) - .await - .unwrap(); + .await?; get_app_config().set_prev_request(response.clone()); - let response_json_result = serde_json::from_str::(response.clone().body.as_str()); - - match response_json_result { - Ok(response_json) => { - let mut out = stdout(); - colored_json::write_colored_json(&response_json, &mut out).unwrap(); - out.flush().unwrap(); - writeln!(out, "").unwrap(); - let mut postscript_env_vars = merged_data.clone(); - postscript_env_vars.extend(param_values); - - api_call - .run_post_command_script( - &serde_json::to_string_pretty(&response.clone()).unwrap(), - &postscript_env_vars, - ) - .unwrap(); - } - Err(_error) => { - println!("{}", response.body); - } - }; + if options.json_output { + println!("{}", serde_json::to_string(&response).unwrap()); + } else { + let response_json_result = serde_json::from_str::(response.clone().body.as_str()); + + match response_json_result { + Ok(response_json) => { + let mut out = stdout(); + colored_json::write_colored_json(&response_json, &mut out).unwrap(); + out.flush().unwrap(); + writeln!(out).unwrap(); + } + Err(_error) => { + println!("{}", response.body); + } + }; + } + + let mut postscript_env_vars = merged_data.clone(); + postscript_env_vars.extend(param_values); + + api_call + .run_post_command_script( + &serde_json::to_string_pretty(&response.clone()).unwrap(), + &postscript_env_vars, + ) + .unwrap(); Ok(()) } diff --git a/src/core/app_config.rs b/src/core/app_config.rs index e29d24e..650b8fd 100644 --- a/src/core/app_config.rs +++ b/src/core/app_config.rs @@ -16,6 +16,8 @@ pub struct AppConfig { ephenvs: HashMap>, #[serde(default)] prev_request: HashMap, + #[serde(default)] + last_seen_version: Option, } impl AppConfig { @@ -24,10 +26,11 @@ impl AppConfig { envs: HashMap::new(), ephenvs: HashMap::new(), prev_request: HashMap::new(), + last_seen_version: None, } } - pub fn save(&self) -> () { + pub fn save(&self) { let app_config_dir = get_app_config_dir(); let app_config_file_path = get_app_config_file_path(); @@ -77,6 +80,15 @@ impl AppConfig { pub fn get_prev_request(&self) -> Option<&Response> { self.prev_request.get(&get_config_key()) } + + pub fn get_last_seen_version(&self) -> Option<&String> { + self.last_seen_version.as_ref() + } + + pub fn set_last_seen_version(&mut self, version: String) { + self.last_seen_version = Some(version); + self.save(); + } } fn get_config_key() -> String { diff --git a/src/core/command.rs b/src/core/command.rs index a8c34a8..89cad23 100644 --- a/src/core/command.rs +++ b/src/core/command.rs @@ -38,6 +38,17 @@ fn get_params_from_string(input: &str) -> Vec { .collect() } +pub fn get_param_types_from_string(input: &str) -> HashMap> { + let typed_param_regex = Regex::new(r":(\w+)(?:\|(\w+))?").unwrap(); + let mut result = HashMap::new(); + for caps in typed_param_regex.captures_iter(input) { + let name = caps.get(1).unwrap().as_str().to_string(); + let param_type = caps.get(2).map(|m| m.as_str().to_string()); + result.insert(name, param_type); + } + result +} + impl Command { pub fn route_params(&self) -> Vec { get_params_from_string(self.url.as_str()) @@ -54,6 +65,13 @@ impl Command { self.route_params().union(self.body_params()) } + pub fn body_param_types(&self) -> HashMap> { + match &self.body { + Some(input) => get_param_types_from_string(&input.to_string()), + None => HashMap::new(), + } + } + pub fn run_post_command_script( &self, command_response: &str, @@ -142,4 +160,34 @@ mod tests { ] ) } + + #[test] + fn test_get_param_types_from_string() { + let input = r#"{"dryRun":":dryRun|boolean","limit":":limit|number","name":":name"}"#; + let types = get_param_types_from_string(input); + assert_eq!(types.get("dryRun"), Some(&Some("boolean".to_string()))); + assert_eq!(types.get("limit"), Some(&Some("number".to_string()))); + assert_eq!(types.get("name"), Some(&None)); + } + + #[test] + fn test_body_param_types() { + let cmd = Command { + method: http::HttpMethod::POST, + url: String::from("https://example.com/api"), + headers: HashMap::new(), + body: Some(json!({ + "dryRun": ":dryRun|boolean", + "limit": ":limit|number", + "file": ":filePath|file", + "name": ":name", + })), + postscript: None, + }; + let types = cmd.body_param_types(); + assert_eq!(types.get("dryRun"), Some(&Some("boolean".to_string()))); + assert_eq!(types.get("limit"), Some(&Some("number".to_string()))); + assert_eq!(types.get("filePath"), Some(&Some("file".to_string()))); + assert_eq!(types.get("name"), Some(&None)); + } } diff --git a/src/core/config.rs b/src/core/config.rs index c3753b8..ac4b1b2 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -46,7 +46,7 @@ impl Config { let reader = BufReader::new(file); let config: Config = serde_json::from_reader(reader).expect("Error while reading JSON"); - return config; + config } pub fn save(&self) -> Result<(), std::io::Error> { let file_path = get_config_file_path(); diff --git a/src/core/env.rs b/src/core/env.rs index 306933a..ff5e4ee 100644 --- a/src/core/env.rs +++ b/src/core/env.rs @@ -9,17 +9,13 @@ pub fn get_env() -> Option { None } -pub fn set_env(env: String) -> () { +pub fn set_env(env: String) { let mut app_config = get_app_config(); app_config.set_current_env(env); } pub fn list_envs() -> Vec { - let mut envs = Config::new() - .envs - .keys() - .map(|k| k.clone()) - .collect::>(); + let mut envs = Config::new().envs.keys().cloned().collect::>(); envs.sort(); envs } diff --git a/src/core/ephenv.rs b/src/core/ephenv.rs index 8e3307d..9dedc60 100644 --- a/src/core/ephenv.rs +++ b/src/core/ephenv.rs @@ -9,7 +9,7 @@ pub fn get_ephenvs() -> HashMap { HashMap::new() } -pub fn set_ephenv(key: String, value: String) -> () { +pub fn set_ephenv(key: String, value: String) { let mut app_config = get_app_config(); app_config.set_ephenv(key, value); } diff --git a/src/core/openapi.rs b/src/core/openapi.rs index 6111121..e09e394 100644 --- a/src/core/openapi.rs +++ b/src/core/openapi.rs @@ -38,38 +38,26 @@ pub fn generate_config(spec: &OpenAPI) -> Result> { }; // Process operations (GET, POST, PUT, DELETE, etc.) + process_operation(&mut tag_operations, path, path_item, &path_item.get, "get"); process_operation( &mut tag_operations, - &path, - &path_item, - &path_item.get, - "get", - ); - process_operation( - &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.post, "post", ); + process_operation(&mut tag_operations, path, path_item, &path_item.put, "put"); process_operation( &mut tag_operations, - &path, - &path_item, - &path_item.put, - "put", - ); - process_operation( - &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.delete, "delete", ); process_operation( &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.patch, "patch", ); @@ -128,7 +116,7 @@ fn process_operation<'a>( tag.clone() } else { let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - match segments.get(0) { + match segments.first() { Some(&segment) => segment.to_string(), None => "default".to_string(), } @@ -136,7 +124,7 @@ fn process_operation<'a>( tag_operations .entry(section) - .or_insert_with(Vec::new) + .or_default() .push((path, path_item, operation.clone())); } } @@ -158,25 +146,25 @@ fn create_command_for_operation( if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("get")) + .is_some_and(|id| id.starts_with("get")) { HttpMethod::GET } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("create")) + .is_some_and(|id| id.starts_with("create")) { HttpMethod::POST } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("update")) + .is_some_and(|id| id.starts_with("update")) { HttpMethod::PUT } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("delete")) + .is_some_and(|id| id.starts_with("delete")) { HttpMethod::DELETE } else { diff --git a/src/main.rs b/src/main.rs index 5b129f7..a537de9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,7 @@ mod constants; mod core; mod utils; -use human_panic; use std::process; -use tokio; #[tokio::main] async fn main() -> process::ExitCode { diff --git a/src/utils/error.rs b/src/utils/error.rs index c5152dc..4e36b33 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -4,7 +4,6 @@ use std::fmt; #[derive(Debug)] pub struct CliError { pub message: String, - pub help: Option, } impl fmt::Display for CliError { diff --git a/src/utils/http.rs b/src/utils/http.rs index 337ef08..c4e8a9b 100644 --- a/src/utils/http.rs +++ b/src/utils/http.rs @@ -1,9 +1,14 @@ use reqwest; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; use strum::Display; #[derive(Display, Deserialize, Serialize, Clone, Debug)] +#[expect( + clippy::upper_case_acronyms, + reason = "HTTP method names are conventionally uppercase and are serialized as-is in config files" +)] pub enum HttpMethod { GET, POST, @@ -12,7 +17,7 @@ pub enum HttpMethod { PATCH, } -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct Response { pub url: String, pub status: u16, @@ -25,7 +30,8 @@ pub async fn handle_request( http_method: &HttpMethod, headers: &HashMap, body: Option, -) -> Result { + file_fields: Option>, +) -> Result> { let client = reqwest::Client::new(); let method: reqwest::Method = match http_method { HttpMethod::GET => reqwest::Method::GET, @@ -39,6 +45,15 @@ pub async fn handle_request( let mut owned_headers = headers.clone(); owned_headers.insert("User-Agent".to_string(), "hit-cli".to_string()); + let has_file_fields = file_fields + .as_ref() + .is_some_and(|fields| !fields.is_empty()); + + if has_file_fields { + owned_headers.remove("Content-Type"); + owned_headers.remove("content-type"); + } + let mut headers_map = reqwest::header::HeaderMap::new(); headers_map.extend(owned_headers.into_iter().map(|(k, v)| { @@ -50,15 +65,47 @@ pub async fn handle_request( let request_builder = reqwest::RequestBuilder::from_parts(client, request).headers(headers_map); - let request_builder = match body { - Some(body) => { - if let Ok(json_body) = serde_json::from_str::(&body) { - request_builder.json(&json_body) - } else { - request_builder.body(body) + let request_builder = if has_file_fields { + let file_fields = file_fields.unwrap(); + let mut form = reqwest::multipart::Form::new(); + + for (field_name, file_path) in &file_fields { + let file_bytes = std::fs::read(file_path)?; + let file_name = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name); + form = form.part(field_name.clone(), part); + } + + if let Some(ref body_str) = body { + if let Ok(json_body) = serde_json::from_str::(body_str) { + if let Some(obj) = json_body.as_object() { + for (key, value) in obj { + let text_value = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + form = form.text(key.clone(), text_value); + } + } + } + } + + request_builder.multipart(form) + } else { + match body { + Some(body) => { + if let Ok(json_body) = serde_json::from_str::(&body) { + request_builder.json(&json_body) + } else { + request_builder.body(body) + } } + None => request_builder, } - None => request_builder, }; let response = request_builder.send().await?; diff --git a/src/utils/input.rs b/src/utils/input.rs index 1d742fb..b7e184a 100644 --- a/src/utils/input.rs +++ b/src/utils/input.rs @@ -19,8 +19,7 @@ impl Autocomplete for CustomAutocomplete { .suggestions .iter() .filter(|s| s.to_lowercase().contains(&input_lower)) - // NOTE(meshde): the following line converts Vec<&String> to Vec - .map(|s| s.clone()) + .cloned() .collect()) } fn get_completion( diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 7f357e7..7cb43ec 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -37,6 +37,8 @@ impl SetupFixture { fs::write(&config_path, test_config.to_string()).unwrap(); + seed_app_config(&temp_dir); + Self { temp_dir } } } @@ -51,6 +53,14 @@ pub fn hit_setup(temp_dir: TempDir) -> SetupFixture { SetupFixture::new(temp_dir) } +fn seed_app_config(temp_dir: &TempDir) { + let app_config_path = temp_dir.path().join("config.json"); + let app_config = serde_json::json!({ + "last_seen_version": env!("CARGO_PKG_VERSION") + }); + fs::write(&app_config_path, app_config.to_string()).unwrap(); +} + pub fn get_hit_command_for_dir(dir: &std::path::Path) -> Command { let mut cmd = Command::cargo_bin("hit-cli").expect("could not call hit-cli"); cmd.current_dir(dir); @@ -63,3 +73,36 @@ pub fn get_hit_command_for_setup(setup: &SetupFixture) -> Command { cmd.env("APP_CONFIG_DIR", app_config_dir); return cmd; } + +pub fn setup_with_mock( + temp_dir: TempDir, + server_url: &str, + commands_json: serde_json::Value, +) -> SetupFixture { + let config_path = temp_dir.path().join(".hit").join("config.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + + let test_config = serde_json::json!({ + "envs": { + "dev": { + "API_URL": server_url + } + }, + "commands": commands_json + }); + + fs::write(&config_path, test_config.to_string()).unwrap(); + + seed_app_config(&temp_dir); + + let setup = SetupFixture { temp_dir }; + + // Set env to "dev" + let mut use_cmd = get_hit_command_for_setup(&setup); + use_cmd.args(["env", "use", "dev"]); + use_cmd + .output() + .expect("Failed to set env to dev in setup_with_mock"); + + setup +} diff --git a/tests/run_tests.rs b/tests/run_tests.rs index af3cf0d..125e625 100644 --- a/tests/run_tests.rs +++ b/tests/run_tests.rs @@ -1,7 +1,9 @@ mod fixtures; use assert_cmd::prelude::*; -use fixtures::{get_hit_command_for_setup, hit_setup, SetupFixture}; +use fixtures::{get_hit_command_for_setup, hit_setup, setup_with_mock, temp_dir, SetupFixture}; +use predicates::prelude::*; use rstest::*; +use tempfile::TempDir; #[rstest] fn test_failure_when_env_not_set( @@ -24,3 +26,411 @@ fn test_failure_when_env_not_recognized(hit_setup: SetupFixture) -> () { cmd.args(["run", "get-by-id", "--id", "meshde"]); cmd.assert().failure().stderr("env not recognized\n"); } + +// --- Feature D: Typed Parameter Substitution --- + +#[rstest] +fn test_typed_boolean_param(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "dryRun": true, + "name": "John" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "dryRun": ":dryRun|boolean", + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--dry-run", + "true", + "--name", + "John", + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_typed_number_param(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "limit": 42 + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "limit": ":limit|number" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--limit", "42", "--json"]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_typed_boolean_validation_error( + temp_dir: TempDir, +) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "dryRun": ":dryRun|boolean" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--dry-run", "notabool"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("boolean")); + + Ok(()) +} + +#[rstest] +fn test_typed_number_validation_error(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "limit": ":limit|number" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--limit", "notanumber"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("number")); + + Ok(()) +} + +// --- Feature A: Multipart File Uploads --- + +#[rstest] +fn test_file_upload_multipart(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/upload") + .match_header( + "content-type", + mockito::Matcher::Regex("multipart/form-data.*".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"uploaded"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/upload", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "file": ":filePath|file", + "dryRun": ":dryRun|boolean" + } + } + }); + + // Create a temp CSV file + let csv_path = temp_dir.path().join("test.csv"); + std::fs::write(&csv_path, "col1,col2\nval1,val2\n")?; + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--file-path", + csv_path.to_str().unwrap(), + "--dry-run", + "true", + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_file_not_found_error(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/upload").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/upload", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "file": ":filePath|file" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--file-path", "/nonexistent/path.csv"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("File not found")); + + Ok(()) +} + +// --- Feature B: Non-Interactive Body Handling --- + +#[rstest] +fn test_no_editor_by_default(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "name": "John" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--name", "John", "--json"]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_body_file_flag(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "custom": "payload" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + // Create a temp body file + let body_path = temp_dir.path().join("body.json"); + std::fs::write(&body_path, r#"{"custom": "payload"}"#)?; + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--body-file", + body_path.to_str().unwrap(), + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_body_file_not_found(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--body-file", + "/nonexistent/body.json", + "--json", + ]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Failed to read body file")); + + Ok(()) +} + +// --- Feature C: Structured JSON Output --- + +#[rstest] +fn test_json_output_structure(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/items/123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "GET", + "url": "{{API_URL}}/items/:id" + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--id", "123", "--json"]); + + let output = cmd.output()?; + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout)?; + let json_response: serde_json::Value = serde_json::from_str(&stdout)?; + + assert!(json_response.get("url").is_some()); + assert!(json_response.get("status").is_some()); + assert!(json_response.get("headers").is_some()); + assert!(json_response.get("body").is_some()); + assert_eq!(json_response["status"], 200); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_default_output_not_json_structured( + temp_dir: TempDir, +) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/items/123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "GET", + "url": "{{API_URL}}/items/:id" + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--id", "123"]); + + let output = cmd.output()?; + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout)?; + // Without --json, output should NOT be the structured JSON with url/status/headers/body + assert!(!stdout.starts_with("{\"url\":")); + + mock.assert(); + Ok(()) +}