From 84abbffafa43397a97e9780fb3c2fe992abcce8d Mon Sep 17 00:00:00 2001 From: Alexis Delain Date: Mon, 15 Dec 2025 18:22:10 +0200 Subject: [PATCH 1/5] chore: pedantic clippy & a couple of refactors Signed-off-by: Alexis Delain --- Cargo.toml | 110 +++++++++++++++++++++++++++++ dummyjson-cli/src/main.rs | 6 +- rclib/src/cli.rs | 52 +++++++------- rclib/src/lib.rs | 143 +++++++++++++++++--------------------- rclib/src/mapping.rs | 1 + 5 files changed, 205 insertions(+), 107 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0799287..a35fd84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,113 @@ once_cell = "1" uuid = { version = "1.0", features = ["v4"] } tokio = { version = "1" } chrono = { version = "0.4", features = ["serde"] } + +[workspace.lints.rust] +deprecated = "deny" +non_ascii_idents = "forbid" +unsafe_code = "forbid" +unused_mut = "warn" +noop_method_call = "warn" +unused_import_braces = "warn" + +[workspace.lints.clippy] +# Workspace global rules +pedantic = { level = "warn", priority = -1 } +async_yields_async = "deny" +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +cast_possible_truncation = "warn" +cast_possible_wrap = "warn" +cast_precision_loss = "warn" +cast_sign_loss = "warn" +clone_on_copy = "deny" +cognitive_complexity = "warn" +dbg_macro = "deny" +debug_assert_with_mut_call = "deny" +default_trait_access = "warn" +doc_link_with_quotes = "warn" +doc_markdown = "warn" +elidable_lifetime_names = "deny" +empty_line_after_outer_attr = "warn" +empty_structs_with_brackets = "warn" +enum_glob_use = "deny" +expect_used = "warn" +explicit_iter_loop = "warn" +float_cmp = "warn" +float_cmp_const = "warn" +format_push_string = "warn" +if_not_else = "warn" +ignored_unit_patterns = "warn" +implicit_clone = "warn" +items_after_statements = "warn" +implicit_hasher = "warn" +inefficient_to_string = "warn" +integer_division = "warn" +lossy_float_literal = "warn" +manual_assert = "warn" +manual_let_else = "warn" +manual_string_new = "warn" +many_single_char_names = "warn" +map_clone = "warn" +map_unwrap_or = "warn" +match_same_arms = "warn" +match_wildcard_for_single_variants = "warn" +missing_errors_doc = "warn" +missing_fields_in_debug = "warn" +missing_panics_doc = "warn" +must_use_candidate = "warn" +mutex_atomic = "warn" +needless_borrow = "deny" +needless_lifetimes = "deny" +needless_pass_by_value = "warn" +no_effect_underscore_binding = "warn" +non_ascii_literal = "warn" +redundant_closure_for_method_calls = "warn" +redundant_else = "warn" +return_self_not_must_use = "warn" +semicolon_if_nothing_returned = "warn" +single_char_pattern = "warn" +similar_names = "warn" +single_match_else = "warn" +struct_excessive_bools = "warn" +suspicious_operation_groupings = "warn" +too_many_lines = "warn" +trivially_copy_pass_by_ref = "warn" +type_complexity = "warn" +uninlined_format_args = "warn" +unnecessary_wraps = "warn" +unnested_or_patterns = "warn" +unreadable_literal = "warn" +unused_async = "warn" +unused_self = "warn" +unwrap_used = "warn" +used_underscore_binding = "warn" +useless_let_if_seq = "warn" +wildcard_dependencies = "deny" +wildcard_imports = "deny" + +# Catches redundant/inefficient patterns +redundant_clone = "deny" +redundant_pattern_matching = "warn" +redundant_pub_crate = "warn" +str_to_string = "warn" +manual_ok_or = "warn" +manual_map = "warn" +manual_filter_map = "warn" +manual_find_map = "warn" + +# Prevents verbose/non-idiomatic code +match_bool = "warn" +needless_for_each = "warn" +needless_collect = "warn" +needless_late_init = "warn" +needless_option_as_deref = "warn" + +# Memory efficiency and correctness +large_stack_arrays = "warn" +large_types_passed_by_value = "warn" +rc_buffer = "warn" +rc_mutex = "warn" +string_lit_as_bytes = "warn" +verbose_file_reads = "warn" + diff --git a/dummyjson-cli/src/main.rs b/dummyjson-cli/src/main.rs index 2172a78..5246e2d 100644 --- a/dummyjson-cli/src/main.rs +++ b/dummyjson-cli/src/main.rs @@ -3,10 +3,6 @@ use std::fs; use std::collections::HashMap; use anyhow::{Context, Result}; -use serde_json; -// no direct clap types needed here; delegated to rclib - -use rclib; const EMBEDDED_OPENAPI: &str = include_str!("dummyjson-openapi-spec.yaml"); const EMBEDDED_MAPPING: &str = include_str!("mapping.yaml"); @@ -36,7 +32,7 @@ fn real_main() -> Result<()> { EMBEDDED_OPENAPI.to_string() }; let openapi = rclib::parse_openapi(&openapi_text).context("OpenAPI parsing failed")?; - let default_base_url = openapi.servers.get(0).map(|s| s.url.clone()).unwrap_or_else(|| { + let default_base_url = openapi.servers.first().map(|s| s.url.clone()).unwrap_or_else(|| { "https://dummyjson.com".to_string() }); diff --git a/rclib/src/cli.rs b/rclib/src/cli.rs index 5b0d65a..8de8103 100644 --- a/rclib/src/cli.rs +++ b/rclib/src/cli.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::mapping::*; -use crate::{build_request_from_command, execute_requests_loop, OutputFormat, RequestSpec, RawRequestSpec}; +use crate::{build_request_from_command, execute_requests_loop, ExecutionConfig, OutputFormat, RequestSpec, RawRequestSpec}; #[derive(Default)] struct TreeNode { @@ -88,7 +88,7 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command cmd.pattern .split_whitespace() .filter(|t| !is_placeholder(t)) - .last() + .next_back() .unwrap_or("") .to_string() } else { @@ -292,7 +292,7 @@ fn add_children_subcommands(mut cmd: Command, path: Vec, node: &TreeNode cmd } -pub fn collect_subcommand_path<'a>(matches: &'a ArgMatches) -> (Vec, &'a ArgMatches) { +pub fn collect_subcommand_path(matches: &ArgMatches) -> (Vec, &ArgMatches) { let mut path: Vec = Vec::new(); let mut current = matches; while let Some((name, sub_m)) = current.subcommand() { @@ -307,7 +307,7 @@ pub fn collect_subcommand_path<'a>(matches: &'a ArgMatches) -> (Vec, &'a pub fn print_manual_help(path: &[String], cmd: &CommandSpec) { // NAME let prog = "hscli"; - let title = cmd.about.clone().unwrap_or_else(|| String::new()); + let title = cmd.about.clone().unwrap_or_default(); if title.is_empty() { println!("{} {} -", prog, path.join(" ")); } else { @@ -330,10 +330,8 @@ pub fn print_manual_help(path: &[String], cmd: &CommandSpec) { pub fn pre_scan_value(args: &[String], key: &str) -> Option { for i in 0..args.len() { - if args[i] == key { - if i + 1 < args.len() { - return Some(args[i + 1].clone()); - } + if args[i] == key && i + 1 < args.len() { + return Some(args[i + 1].clone()); } if let Some(rest) = args[i].strip_prefix(&(key.to_string() + "=")) { return Some(rest.to_string()); @@ -348,19 +346,21 @@ pub fn pre_scan_value(args: &[String], key: &str) -> Option { pub type CustomHandlerFn = dyn Fn(&HashMap, &str, bool) -> anyhow::Result<()> + Send + Sync + 'static; +#[derive(Default)] pub struct HandlerRegistry { handlers: HashMap>, } impl HandlerRegistry { - pub fn new() -> Self { Self { handlers: HashMap::new() } } + #[must_use] + pub fn new() -> Self { Self::default() } pub fn register(&mut self, name: &str, f: F) where F: Fn(&HashMap, &str, bool) -> anyhow::Result<()> + Send + Sync + 'static, { self.handlers.insert(name.to_string(), Box::new(f)); } - pub fn get(&self, name: &str) -> Option<&Box> { self.handlers.get(name) } + pub fn get(&self, name: &str) -> Option<&CustomHandlerFn> { self.handlers.get(name).map(AsRef::as_ref) } } pub fn validate_handlers(root: &MappingRoot, registry: &HandlerRegistry) -> anyhow::Result<()> { @@ -422,13 +422,11 @@ fn collect_vars_from_matches(cmd: &CommandSpec, leaf: &ArgMatches) -> (HashMap(&name) { - if let Some(var_name) = arg.name.clone() { vars.insert(var_name.clone(), val.clone()); selected.insert(var_name); } - } else if let Some(def) = &arg.default { - if let Some(var_name) = arg.name.clone() { vars.insert(var_name, def.clone()); } - } else if arg.required.unwrap_or(false) { missing_required = true; } - } + } else if let Some(val) = leaf.get_one::(&name) { + if let Some(var_name) = arg.name.clone() { vars.insert(var_name.clone(), val.clone()); selected.insert(var_name); } + } else if let Some(def) = &arg.default { + if let Some(var_name) = arg.name.clone() { vars.insert(var_name, def.clone()); } + } else if arg.required.unwrap_or(false) { missing_required = true; } } (vars, selected, missing_required) } @@ -443,11 +441,17 @@ pub fn drive_command( let base_url = matches.get_one::("base-url").cloned().unwrap_or_else(|| default_base_url.to_string()); let json_output = matches.get_flag("json-output"); let verbose = matches.get_flag("verbose"); - let conn_timeout_secs = parse_timeout(matches, "conn-timeout"); - let request_timeout_secs = parse_timeout(matches, "timeout"); - let count = matches.get_one::("count").copied(); - let duration_secs = matches.get_one::("duration").copied().unwrap_or(0); - let concurrency = matches.get_one::("concurrency").copied().unwrap_or(1); + + let config = ExecutionConfig { + output: if json_output { OutputFormat::Json } else { OutputFormat::Human }, + conn_timeout_secs: parse_timeout(matches, "conn-timeout"), + request_timeout_secs: parse_timeout(matches, "timeout"), + user_agent, + verbose, + count: matches.get_one::("count").copied(), + duration_secs: matches.get_one::("duration").copied().unwrap_or(0), + concurrency: matches.get_one::("concurrency").copied().unwrap_or(1), + }; // RAW subcommand handled here if let Some(("raw", raw_m)) = matches.subcommand() { @@ -456,7 +460,7 @@ pub fn drive_command( let headers: Vec = raw_m.get_many::("header").map(|v| v.cloned().collect()).unwrap_or_default(); let body = raw_m.get_one::("body").cloned(); let raw_spec = RawRequestSpec { base_url: Some(base_url.clone()), method, endpoint, headers, body, multipart: false, file_fields: HashMap::new(), table_view: None }; - return execute_requests_loop(&RequestSpec::Simple(raw_spec), if json_output { OutputFormat::Json } else { OutputFormat::Human }, conn_timeout_secs, request_timeout_secs, user_agent, verbose, count, duration_secs, concurrency); + return execute_requests_loop(&RequestSpec::Simple(raw_spec), &config); } // Build path->command map and current path @@ -477,7 +481,7 @@ pub fn drive_command( h(vars, &base_url, json_output)?; Ok(0) } - _ => execute_requests_loop(&spec, if json_output { OutputFormat::Json } else { OutputFormat::Human }, conn_timeout_secs, request_timeout_secs, user_agent, verbose, count, duration_secs, concurrency), + _ => execute_requests_loop(&spec, &config), } } else { // Intermediate path: print nested help diff --git a/rclib/src/lib.rs b/rclib/src/lib.rs index ccf973c..b38e1d5 100644 --- a/rclib/src/lib.rs +++ b/rclib/src/lib.rs @@ -57,6 +57,35 @@ pub struct ScenarioSpec { pub vars: HashMap, } +/// Configuration for request execution including timeouts, output format, and load testing options. +#[derive(Debug, Clone)] +pub struct ExecutionConfig<'a> { + pub output: OutputFormat, + pub conn_timeout_secs: Option, + pub request_timeout_secs: Option, + pub user_agent: &'a str, + pub verbose: bool, + pub count: Option, + pub duration_secs: u32, + pub concurrency: u32, +} + +impl<'a> ExecutionConfig<'a> { + #[must_use] + pub fn new(user_agent: &'a str) -> Self { + Self { + output: OutputFormat::Human, + conn_timeout_secs: None, + request_timeout_secs: None, + user_agent, + verbose: false, + count: None, + duration_secs: 0, + concurrency: 1, + } + } +} + /// Parse OpenAPI from YAML or JSON string. pub fn parse_openapi(spec: &str) -> Result { // Try YAML first, then JSON @@ -69,6 +98,24 @@ pub fn parse_openapi(spec: &str) -> Result { Ok(json_attempt) } +/// Apply file overrides: for args with type="file" and file_overrides_value_of, +/// read file content and insert into vars under the target variable name. +fn apply_file_overrides(args: &[mapping::ArgSpec], vars: &mut HashMap) { + for arg in args { + let dominated_var = arg.arg_type.as_deref() == Some("file") + && arg.file_overrides_value_of.is_some() + && arg.name.as_ref().and_then(|n| vars.get(n)).is_some_and(|p| !p.is_empty()); + if !dominated_var { continue; } + + let target_var = arg.file_overrides_value_of.as_ref().unwrap(); + let file_path = vars.get(arg.name.as_ref().unwrap()).unwrap(); + + if let Ok(content) = std::fs::read_to_string(file_path) { + vars.insert(target_var.clone(), content); + } + } +} + /// Build a RequestSpec from a command entry and variable map, handling simple, scenario, and custom handler commands. pub fn build_request_from_command( base_url: Option, @@ -82,28 +129,7 @@ pub fn build_request_from_command( let mut vars_with_builtins = vars.clone(); vars_with_builtins.insert("uuid".to_string(), Uuid::new_v4().to_string()); - // Handle file overrides for custom handler commands - for arg in &cmd.args { - if arg.arg_type.as_deref() == Some("file") { - if let Some(target_var) = &arg.file_overrides_value_of { - if let Some(arg_name) = &arg.name { - if let Some(file_path) = vars_with_builtins.get(arg_name) { - if !file_path.is_empty() { - match std::fs::read_to_string(file_path) { - Ok(file_content) => { - vars_with_builtins.insert(target_var.clone(), file_content); - } - Err(_) => { - // For library code, we'll just skip the file override on error - // The calling code can handle validation if needed - } - } - } - } - } - } - } - } + apply_file_overrides(&cmd.args, &mut vars_with_builtins); return RequestSpec::CustomHandler { handler_name: handler_name.clone(), @@ -117,28 +143,7 @@ pub fn build_request_from_command( let mut vars_with_builtins = vars.clone(); vars_with_builtins.insert("uuid".to_string(), Uuid::new_v4().to_string()); - // Handle file overrides for scenario commands too - for arg in &cmd.args { - if arg.arg_type.as_deref() == Some("file") { - if let Some(target_var) = &arg.file_overrides_value_of { - if let Some(arg_name) = &arg.name { - if let Some(file_path) = vars_with_builtins.get(arg_name) { - if !file_path.is_empty() { - match std::fs::read_to_string(file_path) { - Ok(file_content) => { - vars_with_builtins.insert(target_var.clone(), file_content); - } - Err(_) => { - // For library code, we'll just skip the file override on error - // The calling code can handle validation if needed - } - } - } - } - } - } - } - } + apply_file_overrides(&cmd.args, &mut vars_with_builtins); return RequestSpec::Scenario(ScenarioSpec { base_url, @@ -155,29 +160,7 @@ pub fn build_request_from_command( let mut vars_with_builtins = vars.clone(); vars_with_builtins.insert("uuid".to_string(), Uuid::new_v4().to_string()); - // Handle file overrides: if an arg has type="file" and file-overrides-value-of, - // read the file content and replace the target variable's value - for arg in &cmd.args { - if arg.arg_type.as_deref() == Some("file") { - if let Some(target_var) = &arg.file_overrides_value_of { - if let Some(arg_name) = &arg.name { - if let Some(file_path) = vars_with_builtins.get(arg_name) { - if !file_path.is_empty() { - match std::fs::read_to_string(file_path) { - Ok(file_content) => { - vars_with_builtins.insert(target_var.clone(), file_content); - } - Err(_) => { - // For library code, we'll just skip the file override on error - // The calling code can handle validation if needed - } - } - } - } - } - } - } - } + apply_file_overrides(&cmd.args, &mut vars_with_builtins); // Start with command-level values let mut method = method; @@ -306,15 +289,19 @@ fn execute_worker_request( /// Execute a request with count, duration, and concurrency control pub fn execute_requests_loop( spec: &RequestSpec, - output: OutputFormat, - conn_timeout_secs: Option, - request_timeout_secs: Option, - user_agent: &str, - verbose: bool, - count: Option, - duration_secs: u32, - concurrency: u32, + config: &ExecutionConfig<'_>, ) -> Result { + let ExecutionConfig { + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + verbose, + count, + duration_secs, + concurrency, + } = *config; + // Determine execution mode: duration-based or count-based let use_duration = duration_secs > 0; let target_count = if use_duration { @@ -902,7 +889,7 @@ fn print_human_readable(v: &serde_json::Value, table_view: Option<&Vec>) } // Then print arrays as tables for (k, val) in array_entries { - println!(""); + println!(); println!("{}:", k); if let serde_json::Value::Array(arr) = val { print_array_table(arr, table_view); @@ -1054,8 +1041,8 @@ fn get_value_by_path<'a>(v: &'a serde_json::Value, path: &str) -> &'a serde_json } fn humanize_column_label(path: &str) -> String { - let last = path.split('.').last().unwrap_or(path); - let spaced = last.replace('_', " ").replace('-', " "); + let last = path.split('.').next_back().unwrap_or(path); + let spaced = last.replace(['_', '-'], " "); let mut out_words: Vec = Vec::new(); for w in spaced.split_whitespace() { if w.is_empty() { continue; } diff --git a/rclib/src/mapping.rs b/rclib/src/mapping.rs index 238d1a3..f29a1f2 100644 --- a/rclib/src/mapping.rs +++ b/rclib/src/mapping.rs @@ -184,6 +184,7 @@ pub struct CommandGroup { #[derive(Debug, Clone, Serialize)] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] pub enum CommandNode { Command(CommandSpec), Group(CommandGroup), From 8d5833ad7724169ec33071bd35a39f5d22053300 Mon Sep 17 00:00:00 2001 From: Alexis Delain Date: Tue, 16 Dec 2025 09:38:04 +0200 Subject: [PATCH 2/5] chore: add tests (unit/integration) Signed-off-by: Alexis Delain --- rclib/Cargo.toml | 4 + rclib/src/cli.rs | 1064 +++++++++++++++++- rclib/src/error.rs | 58 + rclib/src/lib.rs | 1738 ++++++++++++++++++++++++++++++ rclib/src/mapping.rs | 428 ++++++++ rclib/tests/integration_tests.rs | 420 ++++++++ 6 files changed, 3711 insertions(+), 1 deletion(-) create mode 100644 rclib/tests/integration_tests.rs diff --git a/rclib/Cargo.toml b/rclib/Cargo.toml index 9ed734a..a9f0ff8 100644 --- a/rclib/Cargo.toml +++ b/rclib/Cargo.toml @@ -19,3 +19,7 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } once_cell = { workspace = true } uuid = { workspace = true } + +[dev-dependencies] +wiremock = "0.6" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/rclib/src/cli.rs b/rclib/src/cli.rs index 8de8103..5fcccc6 100644 --- a/rclib/src/cli.rs +++ b/rclib/src/cli.rs @@ -396,7 +396,7 @@ fn parse_timeout(matches: &ArgMatches, arg_name: &str) -> Option { matches.get_one::(arg_name).and_then(|s| s.parse::().ok()).filter(|v| *v >= 0.0) } -fn collect_vars_from_matches(cmd: &CommandSpec, leaf: &ArgMatches) -> (HashMap, HashSet, bool) { +pub fn collect_vars_from_matches(cmd: &CommandSpec, leaf: &ArgMatches) -> (HashMap, HashSet, bool) { let arg_specs: Vec = if cmd.args.is_empty() { derive_args_from_pattern(&cmd.pattern) } else { cmd.args.clone() }; let mut vars: HashMap = HashMap::new(); let mut selected: HashSet = HashSet::new(); @@ -495,3 +495,1065 @@ pub fn drive_command( Ok(0) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== pre_scan_value tests ==================== + + #[test] + fn test_pre_scan_value_space_separated() { + let args = vec![ + "cli".to_string(), + "--base-url".to_string(), + "https://api.example.com".to_string(), + "users".to_string(), + ]; + let result = pre_scan_value(&args, "--base-url"); + assert_eq!(result, Some("https://api.example.com".to_string())); + } + + #[test] + fn test_pre_scan_value_equals_separated() { + let args = vec![ + "cli".to_string(), + "--base-url=https://api.example.com".to_string(), + "users".to_string(), + ]; + let result = pre_scan_value(&args, "--base-url"); + assert_eq!(result, Some("https://api.example.com".to_string())); + } + + #[test] + fn test_pre_scan_value_not_found() { + let args = vec!["cli".to_string(), "users".to_string()]; + let result = pre_scan_value(&args, "--base-url"); + assert_eq!(result, None); + } + + #[test] + fn test_pre_scan_value_at_end_no_value() { + let args = vec!["cli".to_string(), "--base-url".to_string()]; + let result = pre_scan_value(&args, "--base-url"); + assert_eq!(result, None); // No value after the key + } + + #[test] + fn test_pre_scan_value_empty_args() { + let args: Vec = vec![]; + let result = pre_scan_value(&args, "--base-url"); + assert_eq!(result, None); + } + + // ==================== HandlerRegistry tests ==================== + + #[test] + fn test_handler_registry_new() { + let reg = HandlerRegistry::new(); + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn test_handler_registry_register_and_get() { + let mut reg = HandlerRegistry::new(); + reg.register("test_handler", |_vars, _base_url, _json| Ok(())); + assert!(reg.get("test_handler").is_some()); + assert!(reg.get("other_handler").is_none()); + } + + // ==================== build_cli tests ==================== + + #[test] + fn test_build_cli_hierarchical_creates_subcommands() { + let yaml = r#" +commands: + - name: users + about: "User management" + subcommands: + - name: list + method: GET + endpoint: /users + - name: get + method: GET + endpoint: /users/{id} +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + // Verify command structure + let subcommands: Vec<_> = app.get_subcommands().collect(); + let users_cmd = subcommands.iter().find(|c| c.get_name() == "users"); + assert!(users_cmd.is_some()); + + // Verify path map has entries + assert!(path_map.contains_key(&vec!["users".to_string(), "list".to_string()])); + assert!(path_map.contains_key(&vec!["users".to_string(), "get".to_string()])); + } + + #[test] + fn test_build_cli_adds_global_args() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Verify global args exist + let args: Vec<_> = app.get_arguments().collect(); + let arg_names: Vec<_> = args.iter().map(|a| a.get_id().as_str()).collect(); + + assert!(arg_names.contains(&"base-url")); + assert!(arg_names.contains(&"json-output")); + assert!(arg_names.contains(&"verbose")); + } + + #[test] + fn test_build_cli_nested_groups() { + let yaml = r#" +commands: + - name: org + about: "Organization commands" + subcommands: + - name: members + about: "Member management" + subcommands: + - name: list + method: GET + endpoint: /org/{org_id}/members +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (_, path_map) = build_cli(&root, "https://api.example.com"); + + // Verify deeply nested path + let path = vec!["org".to_string(), "members".to_string(), "list".to_string()]; + assert!(path_map.contains_key(&path)); + } + + // ==================== validate_handlers tests ==================== + + #[test] + fn test_validate_handlers_all_registered() { + let yaml = r#" +commands: + - name: export + subcommands: + - name: users + custom_handler: export_users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let mut reg = HandlerRegistry::new(); + reg.register("export_users", |_, _, _| Ok(())); + + let result = validate_handlers(&root, ®); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_handlers_missing() { + let yaml = r#" +commands: + - name: export + subcommands: + - name: users + custom_handler: export_users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let reg = HandlerRegistry::new(); // No handlers registered + + let result = validate_handlers(&root, ®); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("export_users")); + } + + // ==================== collect_subcommand_path tests ==================== + + #[test] + fn test_collect_subcommand_path_simple() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "users", "list"]).unwrap(); + + let (path, _leaf) = collect_subcommand_path(&matches); + assert_eq!(path, vec!["users".to_string(), "list".to_string()]); + } + + #[test] + fn test_collect_subcommand_path_empty() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli"]).unwrap(); + + let (path, _) = collect_subcommand_path(&matches); + assert!(path.is_empty()); + } + + // ==================== collect_vars_from_matches tests ==================== + + #[test] + fn test_collect_vars_with_defaults() { + let cmd = CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("limit".to_string()), + default: Some("10".to_string()), + required: Some(false), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + args: + - name: limit + default: "10" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "users", "list"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + + let (vars, _, missing) = collect_vars_from_matches(&cmd, leaf); + assert!(!missing); + assert_eq!(vars.get("limit"), Some(&"10".to_string())); + } + + #[test] + fn test_collect_vars_with_provided_value() { + let cmd = CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("limit".to_string()), + long: Some("limit".to_string()), + default: Some("10".to_string()), + required: Some(false), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + args: + - name: limit + long: limit + default: "10" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "users", "list", "--limit", "50"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + + let (vars, selected, _) = collect_vars_from_matches(&cmd, leaf); + assert_eq!(vars.get("limit"), Some(&"50".to_string())); + assert!(selected.contains("limit")); + } + + // ==================== Flat spec handling tests ==================== + + #[test] + fn test_build_cli_flat_spec() { + let yaml = r#" +commands: + - pattern: "users list" + method: GET + endpoint: /users + - pattern: "users get {id}" + method: GET + endpoint: /users/{id} +"#; + let flat = parse_flat_spec(yaml).unwrap(); + let root = MappingRoot::Flat(flat); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + // Verify flat commands are parsed + assert!(path_map.contains_key(&vec!["users".to_string(), "list".to_string()])); + assert!(path_map.contains_key(&vec!["users".to_string(), "get".to_string()])); + + // Verify CLI structure + let subcommands: Vec<_> = app.get_subcommands().collect(); + assert!(subcommands.iter().any(|c| c.get_name() == "users")); + } + + #[test] + fn test_build_cli_with_positional_args() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: get + method: GET + endpoint: /users/{id} + args: + - name: id + positional: true + required: true + help: "User ID" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Should be able to parse positional arg + let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + assert_eq!(path, vec!["users", "get"]); + assert_eq!(leaf.get_one::("id"), Some(&"123".to_string())); + } + + #[test] + fn test_build_cli_with_short_args() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + args: + - name: limit + long: limit + short: l + default: "10" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Should be able to use short arg + let matches = app.try_get_matches_from(["cli", "users", "list", "-l", "25"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + assert_eq!(leaf.get_one::("limit"), Some(&"25".to_string())); + } + + // ==================== leak_str tests ==================== + + #[test] + fn test_leak_str() { + let leaked = leak_str("test string"); + assert_eq!(leaked, "test string"); + } + + #[test] + fn test_leak_str_from_string() { + let s = String::from("dynamic string"); + let leaked = leak_str(s); + assert_eq!(leaked, "dynamic string"); + } + + // ==================== TreeNode tests ==================== + + #[test] + fn test_tree_node_default() { + let node = TreeNode::default(); + assert!(node.children.is_empty()); + assert!(node.args.is_empty()); + assert!(node.about.is_none()); + } + + // ==================== print_manual_help tests ==================== + + #[test] + fn test_print_manual_help_with_about() { + let cmd = CommandSpec { + name: Some("list".to_string()), + about: Some("List all users".to_string()), + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("limit".to_string()), + long: Some("limit".to_string()), + help: Some("Maximum results".to_string()), + required: Some(false), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + // Just verify it doesn't panic + print_manual_help(&["users".to_string(), "list".to_string()], &cmd); + } + + #[test] + fn test_print_manual_help_without_about() { + let cmd = CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + // Just verify it doesn't panic + print_manual_help(&["users".to_string(), "list".to_string()], &cmd); + } + + #[test] + fn test_print_manual_help_with_required_arg() { + let cmd = CommandSpec { + name: Some("get".to_string()), + about: None, + pattern: "users get".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users/{id}".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("id".to_string()), + long: Some("id".to_string()), + help: Some("User ID".to_string()), + required: Some(true), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + // Just verify it doesn't panic + print_manual_help(&["users".to_string(), "get".to_string()], &cmd); + } + + // ==================== parse_timeout tests ==================== + + #[test] + fn test_parse_timeout_valid() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "--timeout", "60", "test", "cmd"]).unwrap(); + let timeout = parse_timeout(&matches, "timeout"); + assert_eq!(timeout, Some(60.0)); + } + + #[test] + fn test_parse_timeout_zero() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "--timeout", "0", "test", "cmd"]).unwrap(); + let timeout = parse_timeout(&matches, "timeout"); + assert_eq!(timeout, Some(0.0)); // Zero is valid + } + + // ==================== collect_vars_from_matches edge cases ==================== + + #[test] + fn test_collect_vars_missing_required() { + // Test that missing required args are detected + let cmd = CommandSpec { + name: Some("get".to_string()), + about: None, + pattern: "users get".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users/{id}".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("id".to_string()), + long: Some("id".to_string()), + required: Some(true), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + + // Build CLI with the arg defined but not required by clap + let yaml = r#" +commands: + - name: users + subcommands: + - name: get + method: GET + endpoint: /users/{id} + args: + - name: id + long: id +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "users", "get"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + + let (_, _, missing) = collect_vars_from_matches(&cmd, leaf); + assert!(missing); // Required arg is missing + } + + #[test] + fn test_collect_vars_bool_flag_set() { + let cmd = CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + ArgSpec { + name: Some("verbose".to_string()), + long: Some("verbose".to_string()), + arg_type: Some("bool".to_string()), + value: Some(ConditionalValue::Mapping { + if_set: Some("true".to_string()), + if_not_set: Some("false".to_string()), + }), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + args: + - name: verbose + long: verbose + type: bool + value: + if_set: "true" + if_not_set: "false" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Test with flag set + let matches = app.clone().try_get_matches_from(["cli", "users", "list", "--verbose"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + let (vars, _, _) = collect_vars_from_matches(&cmd, leaf); + assert_eq!(vars.get("verbose"), Some(&"true".to_string())); + + // Test without flag + let matches = app.try_get_matches_from(["cli", "users", "list"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + let (vars, _, _) = collect_vars_from_matches(&cmd, leaf); + assert_eq!(vars.get("verbose"), Some(&"false".to_string())); + } + + #[test] + fn test_collect_vars_derives_from_pattern() { + // When args is empty, derive_args_from_pattern is used + let cmd = CommandSpec { + name: Some("get".to_string()), + about: None, + pattern: "users get {id}".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users/{id}".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], // Empty - will derive from pattern + use_common_args: vec![], + }; + + // Use flat spec with pattern which derives args automatically + let yaml = r#" +commands: + - pattern: "users get {id}" + method: GET + endpoint: /users/{id} +"#; + let flat = parse_flat_spec(yaml).unwrap(); + let root = MappingRoot::Flat(flat); + let (app, _) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + + let (vars, _, _) = collect_vars_from_matches(&cmd, leaf); + assert_eq!(vars.get("id"), Some(&"123".to_string())); + } + + // ==================== validate_handlers with flat spec ==================== + + #[test] + fn test_validate_handlers_flat_spec() { + let yaml = r#" +commands: + - pattern: "export users" + custom_handler: export_users +"#; + let flat = parse_flat_spec(yaml).unwrap(); + let root = MappingRoot::Flat(flat); + + let mut reg = HandlerRegistry::new(); + reg.register("export_users", |_, _, _| Ok(())); + + let result = validate_handlers(&root, ®); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_handlers_flat_spec_missing() { + let yaml = r#" +commands: + - pattern: "export users" + custom_handler: missing_handler +"#; + let flat = parse_flat_spec(yaml).unwrap(); + let root = MappingRoot::Flat(flat); + let reg = HandlerRegistry::new(); + + let result = validate_handlers(&root, ®); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing_handler")); + } + + // ==================== arg inheritance tests ==================== + + #[test] + fn test_arg_inheritance_from_common_args() { + let yaml = r#" +commands: + - name: api + common_args: + output_format: + name: format + long: format + default: "json" + help: "Output format" + subcommands: + - name: call + method: GET + endpoint: /api + args: + - inherit: output_format +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "api", "call", "--format", "xml"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + + assert_eq!(vars.get("format"), Some(&"xml".to_string())); + } + + // ==================== add_children_commands tests ==================== + + #[test] + fn test_add_children_commands_with_about() { + let yaml = r#" +commands: + - name: users + about: "User management" + subcommands: + - name: list + about: "List all users" + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let users_cmd = app.get_subcommands().find(|c| c.get_name() == "users"); + assert!(users_cmd.is_some()); + let users = users_cmd.unwrap(); + assert!(users.get_about().is_some()); + } + + #[test] + fn test_add_children_commands_with_default_values() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + args: + - name: limit + long: limit + default: "100" + - name: offset + long: offset + default: "0" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Verify defaults work + let matches = app.try_get_matches_from(["cli", "users", "list"]).unwrap(); + let (_, leaf) = collect_subcommand_path(&matches); + assert_eq!(leaf.get_one::("limit"), Some(&"100".to_string())); + assert_eq!(leaf.get_one::("offset"), Some(&"0".to_string())); + } + + // ==================== merge_arg_specs coverage ==================== + + #[test] + fn test_arg_inheritance_with_override() { + let yaml = r#" +commands: + - name: api + common_args: + base_arg: + name: param + long: param + default: "default_value" + help: "Base help" + short: p + subcommands: + - name: call + method: GET + endpoint: /api + args: + - inherit: base_arg + default: "overridden_value" + help: "Overridden help" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "api", "call"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + + // Should use overridden default + assert_eq!(vars.get("param"), Some(&"overridden_value".to_string())); + } + + #[test] + fn test_arg_inheritance_missing_base() { + // When inherit references non-existent common_arg, use the arg as-is + let yaml = r#" +commands: + - name: api + subcommands: + - name: call + method: GET + endpoint: /api + args: + - inherit: nonexistent + name: fallback + long: fallback + default: "fallback_value" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "api", "call"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + + assert_eq!(vars.get("fallback"), Some(&"fallback_value".to_string())); + } + + // ==================== use_common_args legacy support ==================== + + #[test] + fn test_use_common_args_legacy() { + let yaml = r#" +commands: + - name: api + common_args: + verbose_arg: + name: verbose + long: verbose + type: bool + value: + if_set: "true" + if_not_set: "false" + subcommands: + - name: call + method: GET + endpoint: /api + use_common_args: + - verbose_arg +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "api", "call", "--verbose"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + + assert_eq!(vars.get("verbose"), Some(&"true".to_string())); + } + + // ==================== command name derivation ==================== + + #[test] + fn test_command_name_from_pattern() { + // When name is not provided, derive from pattern + let yaml = r#" +commands: + - name: users + subcommands: + - pattern: "users list-all" + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (_, path_map) = build_cli(&root, "https://api.example.com"); + + // Should derive "list-all" from pattern + assert!(path_map.contains_key(&vec!["users".to_string(), "list-all".to_string()])); + } + + // ==================== raw command tests ==================== + + #[test] + fn test_raw_command_exists() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Verify raw command exists + let raw_cmd = app.get_subcommands().find(|c| c.get_name() == "raw"); + assert!(raw_cmd.is_some()); + } + + #[test] + fn test_raw_command_args() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + // Parse raw command - collect_subcommand_path stops at "raw" + let matches = app.try_get_matches_from([ + "cli", "raw", + "--method", "POST", + "--endpoint", "/api/test", + "--header", "Content-Type: application/json", + "--body", r#"{"key": "value"}"# + ]).unwrap(); + + // raw subcommand is handled specially - verify we can get the subcommand + if let Some(("raw", raw_m)) = matches.subcommand() { + assert_eq!(raw_m.get_one::("method"), Some(&"POST".to_string())); + assert_eq!(raw_m.get_one::("endpoint"), Some(&"/api/test".to_string())); + } else { + panic!("Expected raw subcommand"); + } + } + + // ==================== global args tests ==================== + + #[test] + fn test_global_args_conn_timeout() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "--conn-timeout", "45", "test", "cmd"]).unwrap(); + let timeout = parse_timeout(&matches, "conn-timeout"); + assert_eq!(timeout, Some(45.0)); + } + + #[test] + fn test_global_args_json_output() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "--json-output", "test", "cmd"]).unwrap(); + assert!(matches.get_flag("json-output")); + } + + #[test] + fn test_global_args_verbose() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "-v", "test", "cmd"]).unwrap(); + assert!(matches.get_flag("verbose")); + } + + // ==================== perf test args ==================== + + #[test] + fn test_perf_args_count() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "--count", "100", "test", "cmd"]).unwrap(); + assert_eq!(matches.get_one::("count"), Some(&100)); + } + + #[test] + fn test_perf_args_duration() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "--duration", "30", "test", "cmd"]).unwrap(); + assert_eq!(matches.get_one::("duration"), Some(&30)); + } + + #[test] + fn test_perf_args_concurrency() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, _) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "--concurrency", "4", "test", "cmd"]).unwrap(); + assert_eq!(matches.get_one::("concurrency"), Some(&4)); + } +} diff --git a/rclib/src/error.rs b/rclib/src/error.rs index e86069f..8d50f3e 100644 --- a/rclib/src/error.rs +++ b/rclib/src/error.rs @@ -40,3 +40,61 @@ impl From for Error { /// A Result type alias for rclib operations. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_generic_display() { + let err = Error::Generic("test error".to_string()); + assert_eq!(format!("{}", err), "test error"); + } + + #[test] + fn test_error_io_display() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = Error::Io(io_err); + assert!(format!("{}", err).contains("IO error")); + } + + #[test] + fn test_error_cli_display() { + let err = Error::CliError("invalid argument".to_string()); + assert_eq!(format!("{}", err), "CLI error: invalid argument"); + } + + #[test] + fn test_error_source_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = Error::Io(io_err); + assert!(std::error::Error::source(&err).is_some()); + } + + #[test] + fn test_error_source_generic() { + let err = Error::Generic("test".to_string()); + assert!(std::error::Error::source(&err).is_none()); + } + + #[test] + fn test_error_source_cli() { + let err = Error::CliError("test".to_string()); + assert!(std::error::Error::source(&err).is_none()); + } + + #[test] + fn test_error_from_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"); + let err: Error = io_err.into(); + assert!(matches!(err, Error::Io(_))); + } + + #[test] + fn test_error_debug() { + let err = Error::Generic("debug test".to_string()); + let debug_str = format!("{:?}", err); + assert!(debug_str.contains("Generic")); + assert!(debug_str.contains("debug test")); + } +} diff --git a/rclib/src/lib.rs b/rclib/src/lib.rs index b38e1d5..7defd49 100644 --- a/rclib/src/lib.rs +++ b/rclib/src/lib.rs @@ -1130,3 +1130,1741 @@ pub fn substitute_template(template: &str, vars: &HashMap) -> St // Re-export useful types for consumers pub use openapiv3; pub use reqwest; + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== substitute_template tests ==================== + + #[test] + fn test_substitute_template_single_var() { + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "John".to_string()); + let result = substitute_template("Hello {name}!", &vars); + assert_eq!(result, "Hello John!"); + } + + #[test] + fn test_substitute_template_multiple_vars() { + let mut vars = HashMap::new(); + vars.insert("first".to_string(), "John".to_string()); + vars.insert("last".to_string(), "Doe".to_string()); + let result = substitute_template("{first} {last}", &vars); + assert_eq!(result, "John Doe"); + } + + #[test] + fn test_substitute_template_missing_var() { + let vars = HashMap::new(); + let result = substitute_template("Hello {name}!", &vars); + assert_eq!(result, "Hello !"); // Missing vars become empty + } + + #[test] + fn test_substitute_template_no_placeholders() { + let vars = HashMap::new(); + let result = substitute_template("Hello World!", &vars); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_substitute_template_empty_template() { + let vars = HashMap::new(); + let result = substitute_template("", &vars); + assert_eq!(result, ""); + } + + #[test] + fn test_substitute_template_repeated_var() { + let mut vars = HashMap::new(); + vars.insert("x".to_string(), "A".to_string()); + let result = substitute_template("{x}{x}{x}", &vars); + assert_eq!(result, "AAA"); + } + + #[test] + fn test_substitute_template_url_path() { + let mut vars = HashMap::new(); + vars.insert("org".to_string(), "acme".to_string()); + vars.insert("id".to_string(), "123".to_string()); + let result = substitute_template("/orgs/{org}/users/{id}", &vars); + assert_eq!(result, "/orgs/acme/users/123"); + } + + #[test] + fn test_substitute_template_json_body() { + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Test".to_string()); + vars.insert("value".to_string(), "42".to_string()); + let template = r#"{"name": "{name}", "value": {value}}"#; + let result = substitute_template(template, &vars); + assert_eq!(result, r#"{"name": "Test", "value": 42}"#); + } + + // ==================== ExecutionConfig tests ==================== + + #[test] + fn test_execution_config_new_defaults() { + let config = ExecutionConfig::new("test-agent/1.0"); + assert_eq!(config.output, OutputFormat::Human); + assert_eq!(config.conn_timeout_secs, None); + assert_eq!(config.request_timeout_secs, None); + assert_eq!(config.user_agent, "test-agent/1.0"); + assert!(!config.verbose); + assert_eq!(config.count, None); + assert_eq!(config.duration_secs, 0); + assert_eq!(config.concurrency, 1); + } + + // ==================== OutputFormat tests ==================== + + #[test] + fn test_output_format_equality() { + assert_eq!(OutputFormat::Json, OutputFormat::Json); + assert_eq!(OutputFormat::Human, OutputFormat::Human); + assert_eq!(OutputFormat::Quiet, OutputFormat::Quiet); + assert_ne!(OutputFormat::Json, OutputFormat::Human); + } + + // ==================== build_request_from_command tests ==================== + + #[test] + fn test_build_request_simple_get() { + let cmd = mapping::CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let vars = HashMap::new(); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.method, "GET"); + assert_eq!(raw.endpoint, "/users"); + assert_eq!(raw.base_url, Some("https://api.example.com".to_string())); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_with_path_params() { + let cmd = mapping::CommandSpec { + name: Some("get".to_string()), + about: None, + pattern: "users get {id}".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users/{id}".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("id".to_string(), "123".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.endpoint, "/users/123"); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_with_body_template() { + let cmd = mapping::CommandSpec { + name: Some("create".to_string()), + about: None, + pattern: "users create".to_string(), + method: Some("POST".to_string()), + endpoint: Some("/users".to_string()), + body: Some(r#"{"name": "{name}", "email": "{email}"}"#.to_string()), + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "John".to_string()); + vars.insert("email".to_string(), "john@example.com".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.body, Some(r#"{"name": "John", "email": "john@example.com"}"#.to_string())); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_with_header_template() { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer {token}".to_string()); + + let cmd = mapping::CommandSpec { + name: Some("get".to_string()), + about: None, + pattern: "api call".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/api".to_string()), + body: None, + headers, + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("token".to_string(), "secret123".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.headers.iter().any(|h| h.contains("Bearer secret123"))); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_custom_handler() { + let cmd = mapping::CommandSpec { + name: Some("export".to_string()), + about: None, + pattern: "export users".to_string(), + method: None, + endpoint: None, + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: Some("export_users".to_string()), + args: vec![], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("format".to_string(), "csv".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + assert_eq!(handler_name, "export_users"); + assert_eq!(handler_vars.get("format"), Some(&"csv".to_string())); + assert!(handler_vars.contains_key("uuid")); // Built-in variable added + } else { + panic!("Expected RequestSpec::CustomHandler"); + } + } + + #[test] + fn test_build_request_scenario() { + let scenario = mapping::Scenario { + scenario_type: "sequential".to_string(), + steps: vec![ + mapping::ScenarioStep { + name: "step1".to_string(), + method: "POST".to_string(), + endpoint: "/start".to_string(), + body: None, + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: None, + }, + ], + }; + + let cmd = mapping::CommandSpec { + name: Some("deploy".to_string()), + about: None, + pattern: "deploy".to_string(), + method: None, + endpoint: None, + body: None, + headers: HashMap::new(), + table_view: None, + scenario: Some(scenario), + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let vars = HashMap::new(); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Scenario(scenario_spec) = spec { + assert_eq!(scenario_spec.base_url, Some("https://api.example.com".to_string())); + assert_eq!(scenario_spec.scenario.steps.len(), 1); + assert!(scenario_spec.vars.contains_key("uuid")); // Built-in variable added + } else { + panic!("Expected RequestSpec::Scenario"); + } + } + + // ==================== parse_openapi tests ==================== + + #[test] + fn test_parse_openapi_yaml() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +servers: + - url: https://api.example.com +paths: {} +"#; + let api = parse_openapi(yaml).unwrap(); + assert_eq!(api.info.title, "Test API"); + assert_eq!(api.servers.len(), 1); + assert_eq!(api.servers[0].url, "https://api.example.com"); + } + + #[test] + fn test_parse_openapi_json() { + let json = r#"{ + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": {} +}"#; + let api = parse_openapi(json).unwrap(); + assert_eq!(api.info.title, "Test API"); + } + + #[test] + fn test_parse_openapi_invalid() { + let invalid = "not valid openapi"; + let result = parse_openapi(invalid); + assert!(result.is_err()); + } + + // ==================== RawRequestSpec tests ==================== + + #[test] + fn test_raw_request_spec_defaults() { + let spec = RawRequestSpec { + base_url: None, + method: "GET".to_string(), + endpoint: "/test".to_string(), + headers: vec![], + body: None, + multipart: false, + file_fields: HashMap::new(), + table_view: None, + }; + assert!(spec.base_url.is_none()); + assert!(spec.headers.is_empty()); + assert!(!spec.multipart); + } + + // ==================== build_url tests ==================== + + #[test] + fn test_build_url_absolute_endpoint() { + let result = build_url(&None, "https://api.example.com/users"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com/users"); + } + + #[test] + fn test_build_url_http_absolute() { + let result = build_url(&None, "http://localhost:8080/api"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "http://localhost:8080/api"); + } + + #[test] + fn test_build_url_relative_with_base() { + let result = build_url(&Some("https://api.example.com".to_string()), "/users"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com/users"); + } + + #[test] + fn test_build_url_relative_no_base() { + let result = build_url(&None, "/users"); + assert!(result.is_err()); + } + + #[test] + fn test_build_url_base_with_trailing_slash() { + let result = build_url(&Some("https://api.example.com/".to_string()), "/users"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com/users"); + } + + #[test] + fn test_build_url_no_slashes() { + let result = build_url(&Some("https://api.example.com".to_string()), "users"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com/users"); + } + + // ==================== parse_method tests ==================== + + #[test] + fn test_parse_method_get() { + assert_eq!(parse_method("GET").unwrap(), Method::GET); + assert_eq!(parse_method("get").unwrap(), Method::GET); + } + + #[test] + fn test_parse_method_post() { + assert_eq!(parse_method("POST").unwrap(), Method::POST); + } + + #[test] + fn test_parse_method_put() { + assert_eq!(parse_method("PUT").unwrap(), Method::PUT); + } + + #[test] + fn test_parse_method_patch() { + assert_eq!(parse_method("PATCH").unwrap(), Method::PATCH); + } + + #[test] + fn test_parse_method_delete() { + assert_eq!(parse_method("DELETE").unwrap(), Method::DELETE); + } + + #[test] + fn test_parse_method_head() { + assert_eq!(parse_method("HEAD").unwrap(), Method::HEAD); + } + + #[test] + fn test_parse_method_options() { + assert_eq!(parse_method("OPTIONS").unwrap(), Method::OPTIONS); + } + + #[test] + fn test_parse_method_invalid() { + let result = parse_method("INVALID"); + assert!(result.is_err()); + } + + // ==================== parse_headers tests ==================== + + #[test] + fn test_parse_headers_valid() { + let headers = vec!["Content-Type: application/json".to_string()]; + let result = parse_headers(&headers).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result.get("content-type").unwrap(), "application/json"); + } + + #[test] + fn test_parse_headers_multiple() { + let headers = vec![ + "Content-Type: application/json".to_string(), + "Authorization: Bearer token123".to_string(), + ]; + let result = parse_headers(&headers).unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_parse_headers_empty() { + let headers: Vec = vec![]; + let result = parse_headers(&headers).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_headers_invalid_format() { + let headers = vec!["InvalidHeader".to_string()]; + let result = parse_headers(&headers); + assert!(result.is_err()); + } + + #[test] + fn test_parse_headers_value_with_colon() { + let headers = vec!["X-Custom: value:with:colons".to_string()]; + let result = parse_headers(&headers).unwrap(); + assert_eq!(result.get("x-custom").unwrap(), "value:with:colons"); + } + + // ==================== scalar_to_string tests ==================== + + #[test] + fn test_scalar_to_string_null() { + assert_eq!(scalar_to_string(&serde_json::Value::Null), "null"); + } + + #[test] + fn test_scalar_to_string_bool() { + assert_eq!(scalar_to_string(&serde_json::json!(true)), "true"); + assert_eq!(scalar_to_string(&serde_json::json!(false)), "false"); + } + + #[test] + fn test_scalar_to_string_number() { + assert_eq!(scalar_to_string(&serde_json::json!(42)), "42"); + assert_eq!(scalar_to_string(&serde_json::json!(3.14)), "3.14"); + } + + #[test] + fn test_scalar_to_string_string() { + assert_eq!(scalar_to_string(&serde_json::json!("hello")), "hello"); + } + + #[test] + fn test_scalar_to_string_object() { + let obj = serde_json::json!({"key": "value"}); + let result = scalar_to_string(&obj); + assert!(result.contains("key")); + } + + // ==================== humanize_column_label tests ==================== + + #[test] + fn test_humanize_column_label_simple() { + assert_eq!(humanize_column_label("user_name"), "User Name"); + } + + #[test] + fn test_humanize_column_label_with_path() { + assert_eq!(humanize_column_label("user.first_name"), "First Name"); + } + + #[test] + fn test_humanize_column_label_dashes() { + assert_eq!(humanize_column_label("created-at"), "Created At"); + } + + #[test] + fn test_humanize_column_label_mixed() { + assert_eq!(humanize_column_label("user_id-value"), "User Id Value"); + } + + // ==================== parse_column_spec tests ==================== + + #[test] + fn test_parse_column_spec_simple() { + let spec = parse_column_spec("user_name"); + assert_eq!(spec.path, "user_name"); + assert!(spec.modifier.is_none()); + } + + #[test] + fn test_parse_column_spec_with_gb() { + let spec = parse_column_spec("size:gb"); + assert_eq!(spec.path, "size"); + assert!(matches!(spec.modifier, Some(SizeModifier::Gigabytes))); + } + + #[test] + fn test_parse_column_spec_with_mb() { + let spec = parse_column_spec("size:mb"); + assert_eq!(spec.path, "size"); + assert!(matches!(spec.modifier, Some(SizeModifier::Megabytes))); + } + + #[test] + fn test_parse_column_spec_with_kb() { + let spec = parse_column_spec("size:kb"); + assert_eq!(spec.path, "size"); + assert!(matches!(spec.modifier, Some(SizeModifier::Kilobytes))); + } + + #[test] + fn test_parse_column_spec_unknown_modifier() { + let spec = parse_column_spec("size:unknown"); + assert_eq!(spec.path, "size"); + assert!(spec.modifier.is_none()); + } + + // ==================== get_value_by_path tests ==================== + + #[test] + fn test_get_value_by_path_simple() { + let json = serde_json::json!({"name": "John"}); + let result = get_value_by_path(&json, "name"); + assert_eq!(result, &serde_json::json!("John")); + } + + #[test] + fn test_get_value_by_path_nested() { + let json = serde_json::json!({"user": {"name": "John"}}); + let result = get_value_by_path(&json, "user.name"); + assert_eq!(result, &serde_json::json!("John")); + } + + #[test] + fn test_get_value_by_path_missing() { + let json = serde_json::json!({"name": "John"}); + let result = get_value_by_path(&json, "missing"); + assert_eq!(result, &serde_json::Value::Null); + } + + #[test] + fn test_get_value_by_path_non_object() { + let json = serde_json::json!("string"); + let result = get_value_by_path(&json, "field"); + assert_eq!(result, &serde_json::Value::Null); + } + + // ==================== scalar_to_string_with_modifier tests ==================== + + #[test] + fn test_scalar_to_string_with_modifier_gb() { + let bytes = serde_json::json!(1073741824); // 1 GB + let result = scalar_to_string_with_modifier(&bytes, &Some(SizeModifier::Gigabytes)); + assert_eq!(result, "1.00"); + } + + #[test] + fn test_scalar_to_string_with_modifier_mb() { + let bytes = serde_json::json!(1048576); // 1 MB + let result = scalar_to_string_with_modifier(&bytes, &Some(SizeModifier::Megabytes)); + assert_eq!(result, "1.00"); + } + + #[test] + fn test_scalar_to_string_with_modifier_kb() { + let bytes = serde_json::json!(1024); // 1 KB + let result = scalar_to_string_with_modifier(&bytes, &Some(SizeModifier::Kilobytes)); + assert_eq!(result, "1.00"); + } + + #[test] + fn test_scalar_to_string_with_modifier_string_number() { + let bytes = serde_json::json!("1048576"); // 1 MB as string + let result = scalar_to_string_with_modifier(&bytes, &Some(SizeModifier::Megabytes)); + assert_eq!(result, "1.00"); + } + + #[test] + fn test_scalar_to_string_with_modifier_non_numeric_string() { + let value = serde_json::json!("not a number"); + let result = scalar_to_string_with_modifier(&value, &Some(SizeModifier::Megabytes)); + assert_eq!(result, "not a number"); + } + + #[test] + fn test_scalar_to_string_with_modifier_none() { + let value = serde_json::json!(42); + let result = scalar_to_string_with_modifier(&value, &None); + assert_eq!(result, "42"); + } + + #[test] + fn test_scalar_to_string_with_modifier_null() { + let value = serde_json::Value::Null; + let result = scalar_to_string_with_modifier(&value, &Some(SizeModifier::Gigabytes)); + assert_eq!(result, "null"); + } + + // ==================== humanize_column_label_with_modifier tests ==================== + + #[test] + fn test_humanize_column_label_with_modifier_gb() { + let result = humanize_column_label_with_modifier("disk_size", &Some(SizeModifier::Gigabytes)); + assert!(result.contains("Disk Size")); + assert!(result.contains("GB")); + } + + #[test] + fn test_humanize_column_label_with_modifier_mb() { + let result = humanize_column_label_with_modifier("memory", &Some(SizeModifier::Megabytes)); + assert!(result.contains("Memory")); + assert!(result.contains("MB")); + } + + #[test] + fn test_humanize_column_label_with_modifier_kb() { + let result = humanize_column_label_with_modifier("cache_size", &Some(SizeModifier::Kilobytes)); + assert!(result.contains("Cache Size")); + assert!(result.contains("KB")); + } + + #[test] + fn test_humanize_column_label_with_modifier_none() { + let result = humanize_column_label_with_modifier("user_name", &None); + assert_eq!(result, "User Name"); + } + + // ==================== apply_file_overrides tests ==================== + + #[test] + fn test_apply_file_overrides_no_file_args() { + let args = vec![ + mapping::ArgSpec { + name: Some("name".to_string()), + arg_type: None, + ..Default::default() + }, + ]; + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "John".to_string()); + apply_file_overrides(&args, &mut vars); + assert_eq!(vars.get("name"), Some(&"John".to_string())); + } + + #[test] + fn test_apply_file_overrides_file_arg_empty_path() { + let args = vec![ + mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }, + ]; + let mut vars = HashMap::new(); + vars.insert("config_file".to_string(), "".to_string()); + apply_file_overrides(&args, &mut vars); + assert!(!vars.contains_key("config")); + } + + #[test] + fn test_apply_file_overrides_missing_file() { + let args = vec![ + mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }, + ]; + let mut vars = HashMap::new(); + vars.insert("config_file".to_string(), "/nonexistent/path/file.txt".to_string()); + apply_file_overrides(&args, &mut vars); + // Should not insert config since file doesn't exist + assert!(!vars.contains_key("config")); + } + + // ==================== extract_jsonpath_value tests ==================== + + #[test] + fn test_extract_jsonpath_value_string() { + let json = serde_json::json!({"name": "John"}); + let result = extract_jsonpath_value(&json, "$.name"); + assert_eq!(result, Some("John".to_string())); + } + + #[test] + fn test_extract_jsonpath_value_number() { + let json = serde_json::json!({"age": 30}); + let result = extract_jsonpath_value(&json, "$.age"); + assert_eq!(result, Some("30".to_string())); + } + + #[test] + fn test_extract_jsonpath_value_bool() { + let json = serde_json::json!({"active": true}); + let result = extract_jsonpath_value(&json, "$.active"); + assert_eq!(result, Some("true".to_string())); + } + + #[test] + fn test_extract_jsonpath_value_nested() { + let json = serde_json::json!({"user": {"id": 123}}); + let result = extract_jsonpath_value(&json, "$.user.id"); + assert_eq!(result, Some("123".to_string())); + } + + #[test] + fn test_extract_jsonpath_value_missing() { + let json = serde_json::json!({"name": "John"}); + let result = extract_jsonpath_value(&json, "$.missing"); + assert!(result.is_none()); + } + + #[test] + fn test_extract_jsonpath_value_object() { + let json = serde_json::json!({"user": {"name": "John"}}); + let result = extract_jsonpath_value(&json, "$.user"); + assert!(result.is_some()); + assert!(result.unwrap().contains("John")); + } + + // ==================== build_request_from_command edge cases ==================== + + #[test] + fn test_build_request_with_arg_overrides() { + let cmd = mapping::CommandSpec { + name: Some("test".to_string()), + about: None, + pattern: "test".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/default".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("override_arg".to_string()), + endpoint: Some("/overridden".to_string()), + method: Some("POST".to_string()), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let vars = HashMap::new(); + let mut selected = HashSet::new(); + selected.insert("override_arg".to_string()); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.endpoint, "/overridden"); + assert_eq!(raw.method, "POST"); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_multipart() { + let cmd = mapping::CommandSpec { + name: Some("upload".to_string()), + about: None, + pattern: "upload".to_string(), + method: Some("POST".to_string()), + endpoint: Some("/upload".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: true, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("file".to_string()), + file_upload: true, + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("file".to_string(), "/path/to/file.txt".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.multipart); + assert!(raw.file_fields.contains_key("file")); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + // ==================== extract_response_variables tests ==================== + + #[test] + fn test_extract_response_variables_empty() { + let extractions = HashMap::new(); + let mut vars = HashMap::new(); + let result = extract_response_variables("{}", &extractions, &mut vars); + assert!(result.is_ok()); + } + + #[test] + fn test_extract_response_variables_success() { + let mut extractions = HashMap::new(); + extractions.insert("user_id".to_string(), "$.id".to_string()); + let mut vars = HashMap::new(); + let result = extract_response_variables(r#"{"id": "123"}"#, &extractions, &mut vars); + assert!(result.is_ok()); + assert_eq!(vars.get("user_id"), Some(&"123".to_string())); + } + + #[test] + fn test_extract_response_variables_invalid_json() { + let mut extractions = HashMap::new(); + extractions.insert("user_id".to_string(), "$.id".to_string()); + let mut vars = HashMap::new(); + let result = extract_response_variables("not json", &extractions, &mut vars); + assert!(result.is_err()); + } + + #[test] + fn test_extract_response_variables_missing_path() { + let mut extractions = HashMap::new(); + extractions.insert("user_id".to_string(), "$.missing".to_string()); + let mut vars = HashMap::new(); + let result = extract_response_variables(r#"{"id": "123"}"#, &extractions, &mut vars); + assert!(result.is_err()); + } + + // ==================== build_raw_spec_from_step tests ==================== + + #[test] + fn test_build_raw_spec_from_step() { + let step = mapping::ScenarioStep { + name: "test_step".to_string(), + method: "POST".to_string(), + endpoint: "/api/{id}".to_string(), + body: Some(r#"{"name": "{name}"}"#.to_string()), + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: None, + }; + let mut vars = HashMap::new(); + vars.insert("id".to_string(), "123".to_string()); + vars.insert("name".to_string(), "Test".to_string()); + + let result = build_raw_spec_from_step(&Some("https://api.example.com".to_string()), &step, &vars); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.method, "POST"); + assert_eq!(spec.endpoint, "/api/123"); + assert_eq!(spec.body, Some(r#"{"name": "Test"}"#.to_string())); + } + + #[test] + fn test_build_raw_spec_from_step_with_headers() { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer {token}".to_string()); + let step = mapping::ScenarioStep { + name: "test_step".to_string(), + method: "GET".to_string(), + endpoint: "/api".to_string(), + body: None, + headers, + extract_response: HashMap::new(), + polling: None, + }; + let mut vars = HashMap::new(); + vars.insert("token".to_string(), "secret".to_string()); + + let result = build_raw_spec_from_step(&None, &step, &vars); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert!(spec.headers.iter().any(|h| h.contains("Bearer secret"))); + } + + // ==================== print_human_readable tests ==================== + + #[test] + fn test_print_human_readable_object() { + let json = serde_json::json!({ + "name": "John", + "age": 30, + "active": true + }); + // Just verify it doesn't panic + print_human_readable(&json, None); + } + + #[test] + fn test_print_human_readable_array() { + let json = serde_json::json!([ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ]); + // Just verify it doesn't panic + print_human_readable(&json, None); + } + + #[test] + fn test_print_human_readable_scalar() { + let json = serde_json::json!("simple string"); + print_human_readable(&json, None); + + let json = serde_json::json!(42); + print_human_readable(&json, None); + + let json = serde_json::json!(true); + print_human_readable(&json, None); + } + + #[test] + fn test_print_human_readable_object_with_nested_array() { + let json = serde_json::json!({ + "total": 2, + "users": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ] + }); + print_human_readable(&json, None); + } + + #[test] + fn test_print_human_readable_with_table_view() { + let json = serde_json::json!([ + {"id": 1, "name": "Alice", "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "email": "bob@example.com"} + ]); + let table_view = vec!["id".to_string(), "name".to_string()]; + print_human_readable(&json, Some(&table_view)); + } + + // ==================== print_array_table tests ==================== + + #[test] + fn test_print_array_table_empty() { + let arr: Vec = vec![]; + print_array_table(&arr, None); + } + + #[test] + fn test_print_array_table_simple() { + let arr = vec![ + serde_json::json!({"id": 1, "name": "Alice"}), + serde_json::json!({"id": 2, "name": "Bob"}), + ]; + print_array_table(&arr, None); + } + + #[test] + fn test_print_array_table_with_nested_objects() { + let arr = vec![ + serde_json::json!({"id": 1, "user": {"name": "Alice"}}), + serde_json::json!({"id": 2, "user": {"name": "Bob"}}), + ]; + print_array_table(&arr, None); + } + + #[test] + fn test_print_array_table_with_column_spec() { + let arr = vec![ + serde_json::json!({"id": 1, "name": "Alice", "size": 1073741824_i64}), + serde_json::json!({"id": 2, "name": "Bob", "size": 2147483648_i64}), + ]; + let cols = vec!["id".to_string(), "name".to_string(), "size:gb".to_string()]; + print_array_table(&arr, Some(&cols)); + } + + #[test] + fn test_print_array_table_scalars() { + let arr = vec![ + serde_json::json!("item1"), + serde_json::json!("item2"), + serde_json::json!("item3"), + ]; + print_array_table(&arr, None); + } + + // ==================== RequestSpec tests ==================== + + #[test] + fn test_request_spec_simple_clone() { + let spec = RequestSpec::Simple(RawRequestSpec { + base_url: Some("https://api.example.com".to_string()), + method: "GET".to_string(), + endpoint: "/users".to_string(), + headers: vec!["Content-Type: application/json".to_string()], + body: None, + multipart: false, + file_fields: HashMap::new(), + table_view: None, + }); + let cloned = spec.clone(); + if let RequestSpec::Simple(raw) = cloned { + assert_eq!(raw.method, "GET"); + } + } + + #[test] + fn test_request_spec_scenario_clone() { + let scenario = mapping::Scenario { + scenario_type: "sequential".to_string(), + steps: vec![], + }; + let spec = RequestSpec::Scenario(ScenarioSpec { + base_url: Some("https://api.example.com".to_string()), + scenario, + vars: HashMap::new(), + }); + let cloned = spec.clone(); + assert!(matches!(cloned, RequestSpec::Scenario(_))); + } + + #[test] + fn test_request_spec_custom_handler_clone() { + let mut vars = HashMap::new(); + vars.insert("key".to_string(), "value".to_string()); + let spec = RequestSpec::CustomHandler { + handler_name: "test_handler".to_string(), + vars, + }; + let cloned = spec.clone(); + if let RequestSpec::CustomHandler { handler_name, vars } = cloned { + assert_eq!(handler_name, "test_handler"); + assert_eq!(vars.get("key"), Some(&"value".to_string())); + } + } + + // ==================== ScenarioSpec tests ==================== + + #[test] + fn test_scenario_spec_debug() { + let scenario = mapping::Scenario { + scenario_type: "job_with_polling".to_string(), + steps: vec![], + }; + let spec = ScenarioSpec { + base_url: Some("https://api.example.com".to_string()), + scenario, + vars: HashMap::new(), + }; + let debug_str = format!("{:?}", spec); + assert!(debug_str.contains("ScenarioSpec")); + } + + // ==================== ExecutionConfig tests ==================== + + #[test] + fn test_execution_config_clone() { + let config = ExecutionConfig::new("test-agent"); + let cloned = config.clone(); + assert_eq!(cloned.user_agent, "test-agent"); + } + + #[test] + fn test_execution_config_debug() { + let config = ExecutionConfig::new("test-agent"); + let debug_str = format!("{:?}", config); + assert!(debug_str.contains("ExecutionConfig")); + } + + // ==================== build_request_from_command with header overrides ==================== + + #[test] + fn test_build_request_with_arg_header_override() { + let mut arg_headers = HashMap::new(); + arg_headers.insert("X-Custom".to_string(), "custom-value".to_string()); + + let cmd = mapping::CommandSpec { + name: Some("test".to_string()), + about: None, + pattern: "test".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/test".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("custom_arg".to_string()), + headers: Some(arg_headers), + body: Some(r#"{"override": true}"#.to_string()), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let vars = HashMap::new(); + let mut selected = HashSet::new(); + selected.insert("custom_arg".to_string()); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.headers.iter().any(|h| h.contains("X-Custom"))); + assert_eq!(raw.body, Some(r#"{"override": true}"#.to_string())); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + // ==================== apply_file_overrides with real file ==================== + + #[test] + fn test_apply_file_overrides_with_real_file() { + use std::io::Write; + + // Create a temp file + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("rclib_test_file.txt"); + let mut file = std::fs::File::create(&temp_file).unwrap(); + writeln!(file, "file content here").unwrap(); + drop(file); + + let args = vec![ + mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }, + ]; + let mut vars = HashMap::new(); + vars.insert("config_file".to_string(), temp_file.to_string_lossy().to_string()); + + apply_file_overrides(&args, &mut vars); + + assert!(vars.contains_key("config")); + assert!(vars.get("config").unwrap().contains("file content here")); + + // Cleanup + let _ = std::fs::remove_file(temp_file); + } + + // ==================== OutputFormat tests ==================== + + #[test] + fn test_output_format_debug() { + let format = OutputFormat::Json; + let debug_str = format!("{:?}", format); + assert!(debug_str.contains("Json")); + } + + #[test] + fn test_output_format_clone() { + let format = OutputFormat::Human; + let cloned = format.clone(); + assert_eq!(cloned, OutputFormat::Human); + } + + #[test] + fn test_output_format_copy() { + let format = OutputFormat::Quiet; + let copied: OutputFormat = format; + assert_eq!(copied, OutputFormat::Quiet); + } + + // ==================== RawRequestSpec tests ==================== + + #[test] + fn test_raw_request_spec_clone() { + let spec = RawRequestSpec { + base_url: Some("https://api.example.com".to_string()), + method: "POST".to_string(), + endpoint: "/users".to_string(), + headers: vec!["Content-Type: application/json".to_string()], + body: Some(r#"{"name": "test"}"#.to_string()), + multipart: false, + file_fields: HashMap::new(), + table_view: Some(vec!["id".to_string(), "name".to_string()]), + }; + let cloned = spec.clone(); + assert_eq!(cloned.method, "POST"); + assert_eq!(cloned.body, Some(r#"{"name": "test"}"#.to_string())); + assert!(cloned.table_view.is_some()); + } + + #[test] + fn test_raw_request_spec_debug() { + let spec = RawRequestSpec { + base_url: None, + method: "GET".to_string(), + endpoint: "/test".to_string(), + headers: vec![], + body: None, + multipart: false, + file_fields: HashMap::new(), + table_view: None, + }; + let debug_str = format!("{:?}", spec); + assert!(debug_str.contains("RawRequestSpec")); + assert!(debug_str.contains("GET")); + } + + // ==================== build_request edge cases ==================== + + #[test] + fn test_build_request_with_table_view() { + let cmd = mapping::CommandSpec { + name: Some("list".to_string()), + about: None, + pattern: "users list".to_string(), + method: Some("GET".to_string()), + endpoint: Some("/users".to_string()), + body: None, + headers: HashMap::new(), + table_view: Some(vec!["id".to_string(), "name".to_string(), "email".to_string()]), + scenario: None, + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let vars = HashMap::new(); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.table_view.is_some()); + let tv = raw.table_view.unwrap(); + assert_eq!(tv.len(), 3); + assert!(tv.contains(&"id".to_string())); + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + #[test] + fn test_build_request_multipart_without_file_upload() { + // Multipart true but no file_upload args + let cmd = mapping::CommandSpec { + name: Some("upload".to_string()), + about: None, + pattern: "upload".to_string(), + method: Some("POST".to_string()), + endpoint: Some("/upload".to_string()), + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: true, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("description".to_string()), + file_upload: false, // Not a file upload + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("description".to_string(), "test description".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.multipart); + assert!(raw.file_fields.is_empty()); // No file fields + } else { + panic!("Expected RequestSpec::Simple"); + } + } + + // ==================== Scenario edge cases ==================== + + #[test] + fn test_build_request_scenario_with_vars() { + let scenario = mapping::Scenario { + scenario_type: "job_with_polling".to_string(), + steps: vec![ + mapping::ScenarioStep { + name: "schedule_job".to_string(), + method: "POST".to_string(), + endpoint: "/jobs".to_string(), + body: Some(r#"{"name": "{job_name}"}"#.to_string()), + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: None, + }, + mapping::ScenarioStep { + name: "poll_job".to_string(), + method: "GET".to_string(), + endpoint: "/jobs/{job_id}".to_string(), + body: None, + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: Some(mapping::PollingConfig { + interval_seconds: 5, + timeout_seconds: 300, + completion_conditions: vec![], + }), + }, + ], + }; + + let cmd = mapping::CommandSpec { + name: Some("run_job".to_string()), + about: None, + pattern: "run job".to_string(), + method: None, + endpoint: None, + body: None, + headers: HashMap::new(), + table_view: None, + scenario: Some(scenario), + multipart: false, + custom_handler: None, + args: vec![], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("job_name".to_string(), "test_job".to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Scenario(scenario_spec) = spec { + assert_eq!(scenario_spec.scenario.scenario_type, "job_with_polling"); + assert_eq!(scenario_spec.scenario.steps.len(), 2); + assert!(scenario_spec.vars.contains_key("job_name")); + assert!(scenario_spec.vars.contains_key("uuid")); // Built-in + } else { + panic!("Expected RequestSpec::Scenario"); + } + } + + // ==================== Custom handler edge cases ==================== + + #[test] + fn test_build_request_custom_handler_with_file_override() { + use std::io::Write; + + // Create a temp file + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("rclib_handler_test.txt"); + let mut file = std::fs::File::create(&temp_file).unwrap(); + writeln!(file, "handler file content").unwrap(); + drop(file); + + let cmd = mapping::CommandSpec { + name: Some("process".to_string()), + about: None, + pattern: "process".to_string(), + method: None, + endpoint: None, + body: None, + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: Some("process_handler".to_string()), + args: vec![ + mapping::ArgSpec { + name: Some("input_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("input_content".to_string()), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("input_file".to_string(), temp_file.to_string_lossy().to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(None, &cmd, &vars, &selected); + + if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + assert_eq!(handler_name, "process_handler"); + assert!(handler_vars.contains_key("input_content")); + assert!(handler_vars.get("input_content").unwrap().contains("handler file content")); + } else { + panic!("Expected RequestSpec::CustomHandler"); + } + + // Cleanup + let _ = std::fs::remove_file(temp_file); + } + + // ==================== ExecutionResult tests ==================== + + #[test] + fn test_execution_result_debug() { + let result = ExecutionResult { + duration: std::time::Duration::from_millis(100), + is_success: true, + }; + let debug_str = format!("{:?}", result); + assert!(debug_str.contains("ExecutionResult")); + assert!(debug_str.contains("100")); + } + + #[test] + fn test_execution_result_clone() { + let result = ExecutionResult { + duration: std::time::Duration::from_secs(1), + is_success: false, + }; + let cloned = result.clone(); + assert_eq!(cloned.is_success, false); + assert_eq!(cloned.duration.as_secs(), 1); + } + + // ==================== ColumnSpec tests ==================== + + #[test] + fn test_column_spec_debug() { + let spec = ColumnSpec { + path: "user.name".to_string(), + modifier: Some(SizeModifier::Megabytes), + }; + let debug_str = format!("{:?}", spec); + assert!(debug_str.contains("ColumnSpec")); + assert!(debug_str.contains("user.name")); + } + + #[test] + fn test_column_spec_clone() { + let spec = ColumnSpec { + path: "size".to_string(), + modifier: Some(SizeModifier::Gigabytes), + }; + let cloned = spec.clone(); + assert_eq!(cloned.path, "size"); + assert!(matches!(cloned.modifier, Some(SizeModifier::Gigabytes))); + } + + // ==================== SizeModifier tests ==================== + + #[test] + fn test_size_modifier_debug() { + let gb = SizeModifier::Gigabytes; + let mb = SizeModifier::Megabytes; + let kb = SizeModifier::Kilobytes; + + assert!(format!("{:?}", gb).contains("Gigabytes")); + assert!(format!("{:?}", mb).contains("Megabytes")); + assert!(format!("{:?}", kb).contains("Kilobytes")); + } + + #[test] + fn test_size_modifier_clone() { + let modifier = SizeModifier::Kilobytes; + let cloned = modifier.clone(); + assert!(matches!(cloned, SizeModifier::Kilobytes)); + } + + // ==================== build_request with file overrides in simple request ==================== + + #[test] + fn test_build_request_simple_with_file_override() { + use std::io::Write; + + // Create a temp file + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("rclib_simple_test.json"); + let mut file = std::fs::File::create(&temp_file).unwrap(); + writeln!(file, r#"{{"data": "from file"}}"#).unwrap(); + drop(file); + + let cmd = mapping::CommandSpec { + name: Some("create".to_string()), + about: None, + pattern: "create".to_string(), + method: Some("POST".to_string()), + endpoint: Some("/items".to_string()), + body: Some("{body}".to_string()), + headers: HashMap::new(), + table_view: None, + scenario: None, + multipart: false, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("body_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("body".to_string()), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("body_file".to_string(), temp_file.to_string_lossy().to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Simple(raw) = spec { + assert!(raw.body.is_some()); + assert!(raw.body.unwrap().contains("from file")); + } else { + panic!("Expected RequestSpec::Simple"); + } + + // Cleanup + let _ = std::fs::remove_file(temp_file); + } + + // ==================== build_request with scenario file overrides ==================== + + #[test] + fn test_build_request_scenario_with_file_override() { + use std::io::Write; + + // Create a temp file + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("rclib_scenario_test.json"); + let mut file = std::fs::File::create(&temp_file).unwrap(); + writeln!(file, r#"scenario config content"#).unwrap(); + drop(file); + + let scenario = mapping::Scenario { + scenario_type: "job_with_polling".to_string(), + steps: vec![ + mapping::ScenarioStep { + name: "schedule_job".to_string(), + method: "POST".to_string(), + endpoint: "/jobs".to_string(), + body: Some("{config}".to_string()), + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: None, + }, + mapping::ScenarioStep { + name: "poll_job".to_string(), + method: "GET".to_string(), + endpoint: "/jobs/{job_id}".to_string(), + body: None, + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: Some(mapping::PollingConfig { + interval_seconds: 1, + timeout_seconds: 10, + completion_conditions: vec![], + }), + }, + ], + }; + + let cmd = mapping::CommandSpec { + name: Some("run".to_string()), + about: None, + pattern: "run".to_string(), + method: None, + endpoint: None, + body: None, + headers: HashMap::new(), + table_view: None, + scenario: Some(scenario), + multipart: false, + custom_handler: None, + args: vec![ + mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }, + ], + use_common_args: vec![], + }; + let mut vars = HashMap::new(); + vars.insert("config_file".to_string(), temp_file.to_string_lossy().to_string()); + let selected = HashSet::new(); + let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + + if let RequestSpec::Scenario(scenario_spec) = spec { + assert!(scenario_spec.vars.contains_key("config")); + assert!(scenario_spec.vars.get("config").unwrap().contains("scenario config content")); + } else { + panic!("Expected RequestSpec::Scenario"); + } + + // Cleanup + let _ = std::fs::remove_file(temp_file); + } + + // ==================== humanize_column_label edge cases ==================== + + #[test] + fn test_humanize_column_label_empty() { + let result = humanize_column_label(""); + assert_eq!(result, ""); + } + + #[test] + fn test_humanize_column_label_single_char() { + let result = humanize_column_label("x"); + assert_eq!(result, "X"); + } + + #[test] + fn test_humanize_column_label_all_uppercase() { + let result = humanize_column_label("USER_ID"); + assert_eq!(result, "User Id"); + } + + // ==================== get_value_by_path edge cases ==================== + + #[test] + fn test_get_value_by_path_deeply_nested() { + let json = serde_json::json!({ + "level1": { + "level2": { + "level3": { + "value": "deep" + } + } + } + }); + let result = get_value_by_path(&json, "level1.level2.level3.value"); + assert_eq!(result, &serde_json::json!("deep")); + } + + #[test] + fn test_get_value_by_path_array_value() { + let json = serde_json::json!({ + "items": [1, 2, 3] + }); + let result = get_value_by_path(&json, "items"); + assert!(result.is_array()); + } + + // ==================== print_array_table edge cases ==================== + + #[test] + fn test_print_array_table_mixed_types() { + let arr = vec![ + serde_json::json!({"id": 1, "value": "string"}), + serde_json::json!({"id": 2, "value": 42}), + serde_json::json!({"id": 3, "value": true}), + serde_json::json!({"id": 4, "value": null}), + ]; + print_array_table(&arr, None); + } + + #[test] + fn test_print_array_table_with_all_modifiers() { + let arr = vec![ + serde_json::json!({"gb": 1073741824_i64, "mb": 1048576_i64, "kb": 1024_i64}), + ]; + let cols = vec!["gb:gb".to_string(), "mb:mb".to_string(), "kb:kb".to_string()]; + print_array_table(&arr, Some(&cols)); + } + + // ==================== substitute_template edge cases ==================== + + #[test] + fn test_substitute_template_special_chars() { + let mut vars = HashMap::new(); + vars.insert("query".to_string(), "hello world".to_string()); + let result = substitute_template("/search?q={query}", &vars); + assert_eq!(result, "/search?q=hello world"); + } + + #[test] + fn test_substitute_template_underscore_var() { + let mut vars = HashMap::new(); + vars.insert("user_id".to_string(), "123".to_string()); + vars.insert("org_name".to_string(), "acme".to_string()); + let result = substitute_template("/orgs/{org_name}/users/{user_id}", &vars); + assert_eq!(result, "/orgs/acme/users/123"); + } + + #[test] + fn test_substitute_template_numeric_suffix() { + let mut vars = HashMap::new(); + vars.insert("param1".to_string(), "a".to_string()); + vars.insert("param2".to_string(), "b".to_string()); + let result = substitute_template("{param1}-{param2}", &vars); + assert_eq!(result, "a-b"); + } +} + +// HTTP tests require a running mock server - moved to integration tests +// to avoid async/blocking conflicts with the blocking reqwest client diff --git a/rclib/src/mapping.rs b/rclib/src/mapping.rs index f29a1f2..5f4d01b 100644 --- a/rclib/src/mapping.rs +++ b/rclib/src/mapping.rs @@ -292,3 +292,431 @@ pub fn derive_args_from_pattern(pattern: &str) -> Vec { } args } + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== is_placeholder tests ==================== + + #[test] + fn test_is_placeholder_valid() { + assert!(is_placeholder("{id}")); + assert!(is_placeholder("{user_name}")); + assert!(is_placeholder("{abc}")); + } + + #[test] + fn test_is_placeholder_invalid() { + assert!(!is_placeholder("{}")); + assert!(!is_placeholder("{")); + assert!(!is_placeholder("}")); + assert!(!is_placeholder("id")); + assert!(!is_placeholder("{a")); + assert!(!is_placeholder("a}")); + assert!(!is_placeholder("")); + } + + // ==================== derive_args_from_pattern tests ==================== + + #[test] + fn test_derive_args_single_placeholder() { + let args = derive_args_from_pattern("users get {id}"); + assert_eq!(args.len(), 1); + assert_eq!(args[0].name, Some("id".to_string())); + assert_eq!(args[0].positional, Some(true)); + assert_eq!(args[0].required, Some(true)); + } + + #[test] + fn test_derive_args_multiple_placeholders() { + let args = derive_args_from_pattern("users {org} get {id}"); + assert_eq!(args.len(), 2); + assert_eq!(args[0].name, Some("org".to_string())); + assert_eq!(args[1].name, Some("id".to_string())); + } + + #[test] + fn test_derive_args_no_placeholders() { + let args = derive_args_from_pattern("users list"); + assert!(args.is_empty()); + } + + #[test] + fn test_derive_args_empty_pattern() { + let args = derive_args_from_pattern(""); + assert!(args.is_empty()); + } + + // ==================== parse_flat_spec tests ==================== + + #[test] + fn test_parse_flat_spec_minimal() { + let yaml = r#" +commands: + - pattern: "users list" + method: GET + endpoint: /users +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert_eq!(spec.commands.len(), 1); + assert_eq!(spec.commands[0].pattern, "users list"); + assert_eq!(spec.commands[0].method, Some("GET".to_string())); + assert_eq!(spec.commands[0].endpoint, Some("/users".to_string())); + } + + #[test] + fn test_parse_flat_spec_with_args() { + let yaml = r#" +commands: + - pattern: "users get {id}" + method: GET + endpoint: /users/{id} + args: + - name: id + help: "User ID" + required: true +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert_eq!(spec.commands[0].args.len(), 1); + assert_eq!(spec.commands[0].args[0].name, Some("id".to_string())); + assert_eq!(spec.commands[0].args[0].help, Some("User ID".to_string())); + assert_eq!(spec.commands[0].args[0].required, Some(true)); + } + + #[test] + fn test_parse_flat_spec_with_headers() { + let yaml = r#" +commands: + - pattern: "api call" + method: POST + endpoint: /api + headers: + Authorization: "Bearer {token}" + Content-Type: "application/json" +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert_eq!(spec.commands[0].headers.len(), 2); + assert_eq!( + spec.commands[0].headers.get("Authorization"), + Some(&"Bearer {token}".to_string()) + ); + } + + #[test] + fn test_parse_flat_spec_with_body() { + let yaml = r#" +commands: + - pattern: "users create" + method: POST + endpoint: /users + body: '{"name": "{name}", "email": "{email}"}' +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert!(spec.commands[0].body.is_some()); + assert!(spec.commands[0].body.as_ref().unwrap().contains("{name}")); + } + + #[test] + fn test_parse_flat_spec_with_custom_handler() { + let yaml = r#" +commands: + - pattern: "export users" + custom_handler: export_users + args: + - name: format + default: json +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert_eq!(spec.commands[0].custom_handler, Some("export_users".to_string())); + } + + #[test] + fn test_parse_flat_spec_invalid_yaml() { + let yaml = "not: valid: yaml: ["; + let result = parse_flat_spec(yaml); + assert!(result.is_err()); + } + + // ==================== parse_mapping_root tests ==================== + + #[test] + fn test_parse_mapping_root_detects_hierarchical() { + let yaml = r#" +commands: + - name: users + about: "User management" + subcommands: + - name: list + method: GET + endpoint: /users +"#; + let root = parse_mapping_root(yaml).unwrap(); + assert!(matches!(root, MappingRoot::Hier(_))); + } + + #[test] + fn test_parse_mapping_root_without_commands_key() { + // parse_mapping_root falls back to flat when no 'commands' key + // but FlatSpec requires 'commands', so this tests the fallback path error + let yaml = r#" +other_key: value +"#; + let result = parse_mapping_root(yaml); + assert!(result.is_err()); // Falls back to flat but fails since no 'commands' + } + + #[test] + fn test_parse_flat_spec_directly() { + // Flat specs with 'commands' should use parse_flat_spec directly + let yaml = r#" +commands: + - pattern: "users list" + method: GET + endpoint: /users +"#; + let spec = parse_flat_spec(yaml).unwrap(); + assert_eq!(spec.commands.len(), 1); + } + + #[test] + fn test_parse_hierarchical_nested_groups() { + let yaml = r#" +commands: + - name: org + about: "Organization commands" + subcommands: + - name: users + about: "User commands" + subcommands: + - name: list + method: GET + endpoint: /org/{org_id}/users +"#; + let root = parse_mapping_root(yaml).unwrap(); + if let MappingRoot::Hier(spec) = root { + assert_eq!(spec.commands.len(), 1); + assert_eq!(spec.commands[0].name, "org"); + assert_eq!(spec.commands[0].subcommands.len(), 1); + } else { + panic!("Expected hierarchical spec"); + } + } + + #[test] + fn test_parse_hierarchical_with_common_args() { + let yaml = r#" +common_args: + verbose: + name: verbose + type: bool + help: "Enable verbose output" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users + use_common_args: + - verbose +"#; + let root = parse_mapping_root(yaml).unwrap(); + if let MappingRoot::Hier(spec) = root { + assert!(spec.common_args.contains_key("verbose")); + } else { + panic!("Expected hierarchical spec"); + } + } + + // ==================== CommandNode deserialization tests ==================== + + #[test] + fn test_command_node_deserialize_as_command() { + let yaml = r#" +name: list +method: GET +endpoint: /users +"#; + let node: CommandNode = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(node, CommandNode::Command(_))); + } + + #[test] + fn test_command_node_deserialize_as_group() { + let yaml = r#" +name: users +about: "User commands" +subcommands: + - name: list + method: GET + endpoint: /users +"#; + let node: CommandNode = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(node, CommandNode::Group(_))); + } + + #[test] + fn test_command_node_deserialize_with_scenario() { + let yaml = r#" +name: deploy +scenario: + type: sequential + steps: + - name: step1 + method: POST + endpoint: /deploy +"#; + let node: CommandNode = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(node, CommandNode::Command(_))); + if let CommandNode::Command(cmd) = node { + assert!(cmd.scenario.is_some()); + } + } + + #[test] + fn test_command_node_deserialize_default_to_command() { + // When no method/endpoint/scenario/subcommands, should default to Command + let yaml = r#" +name: test +pattern: "test cmd" +"#; + let node: CommandNode = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(node, CommandNode::Command(_))); + } + + // ==================== MappingRoot tests ==================== + + #[test] + fn test_mapping_root_debug() { + let yaml = r#" +commands: + - name: test + subcommands: + - name: cmd + method: GET + endpoint: /test +"#; + let root = parse_mapping_root(yaml).unwrap(); + let debug_str = format!("{:?}", root); + assert!(debug_str.contains("Hier")); + } + + #[test] + fn test_mapping_root_flat_debug() { + let yaml = r#" +commands: + - pattern: "test cmd" + method: GET + endpoint: /test +"#; + let flat = parse_flat_spec(yaml).unwrap(); + let root = MappingRoot::Flat(flat); + let debug_str = format!("{:?}", root); + assert!(debug_str.contains("Flat")); + } + + // ==================== Scenario parsing tests ==================== + + #[test] + fn test_parse_command_with_scenario() { + let yaml = r#" +commands: + - pattern: "deploy" + scenario: + type: sequential + steps: + - name: create + method: POST + endpoint: /deployments + extract_response: + deployment_id: "$.id" + - name: wait + method: GET + endpoint: /deployments/{deployment_id} + polling: + interval_seconds: 5 + timeout_seconds: 300 + completion_conditions: + - status: completed + action: success + - status: failed + action: fail +"#; + let spec = parse_flat_spec(yaml).unwrap(); + let scenario = spec.commands[0].scenario.as_ref().unwrap(); + assert_eq!(scenario.scenario_type, "sequential"); + assert_eq!(scenario.steps.len(), 2); + assert_eq!(scenario.steps[0].name, "create"); + assert!(scenario.steps[0].extract_response.contains_key("deployment_id")); + assert!(scenario.steps[1].polling.is_some()); + } + + // ==================== ArgSpec with conditional values ==================== + + #[test] + fn test_parse_arg_with_conditional_value_mapping() { + let yaml = r#" +commands: + - pattern: "users list" + method: GET + endpoint: /users + args: + - name: verbose + type: bool + value: + if_set: "true" + if_not_set: "false" +"#; + let spec = parse_flat_spec(yaml).unwrap(); + let arg = &spec.commands[0].args[0]; + assert!(arg.value.is_some()); + if let Some(ConditionalValue::Mapping { if_set, if_not_set }) = &arg.value { + assert_eq!(if_set, &Some("true".to_string())); + assert_eq!(if_not_set, &Some("false".to_string())); + } else { + panic!("Expected ConditionalValue::Mapping"); + } + } + + // ==================== Table view parsing ==================== + + #[test] + fn test_parse_command_with_table_view() { + let yaml = r#" +commands: + - pattern: "users list" + method: GET + endpoint: /users + table_view: + - id + - name + - email +"#; + let spec = parse_flat_spec(yaml).unwrap(); + let table_view = spec.commands[0].table_view.as_ref().unwrap(); + assert_eq!(table_view.len(), 3); + assert_eq!(table_view[0], "id"); + } + + // ==================== File override parsing ==================== + + #[test] + fn test_parse_arg_with_file_override() { + let yaml = r#" +commands: + - pattern: "config update" + method: PUT + endpoint: /config + body: "{config_content}" + args: + - name: config_file + type: file + file-overrides-value-of: config_content + help: "Path to config file" +"#; + let spec = parse_flat_spec(yaml).unwrap(); + let arg = &spec.commands[0].args[0]; + assert_eq!(arg.arg_type, Some("file".to_string())); + assert_eq!(arg.file_overrides_value_of, Some("config_content".to_string())); + } +} diff --git a/rclib/tests/integration_tests.rs b/rclib/tests/integration_tests.rs new file mode 100644 index 0000000..1e248d6 --- /dev/null +++ b/rclib/tests/integration_tests.rs @@ -0,0 +1,420 @@ +//! Integration tests for rclib +//! +//! These tests verify that the various modules work together correctly. + +use rclib::{ + build_request_from_command, + mapping::{parse_mapping_root, MappingRoot}, + cli::{build_cli, collect_subcommand_path, collect_vars_from_matches, HandlerRegistry, validate_handlers}, + RequestSpec, + OutputFormat, + ExecutionConfig, +}; + +// ==================== Mapping → CLI Integration ==================== + +#[test] +fn test_full_cli_workflow_hierarchical() { + // 1. Parse YAML mapping + let yaml = r#" +commands: + - name: users + about: "User management commands" + subcommands: + - name: list + about: "List all users" + method: GET + endpoint: /users + args: + - name: limit + long: limit + default: "10" + help: "Maximum number of users" + - name: get + about: "Get a specific user" + method: GET + endpoint: /users/{id} + args: + - name: id + positional: true + required: true + help: "User ID" +"#; + let root = parse_mapping_root(yaml).unwrap(); + assert!(matches!(root, MappingRoot::Hier(_))); + + // 2. Build CLI from mapping + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + // 3. Verify CLI structure + assert!(path_map.contains_key(&vec!["users".to_string(), "list".to_string()])); + assert!(path_map.contains_key(&vec!["users".to_string(), "get".to_string()])); + + // 4. Parse arguments + let matches = app.try_get_matches_from(["cli", "users", "list", "--limit", "25"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + assert_eq!(path, vec!["users", "list"]); + + // 5. Get command and collect variables + let cmd = path_map.get(&path).unwrap(); + let (vars, selected, missing) = collect_vars_from_matches(cmd, leaf); + assert!(!missing); + assert_eq!(vars.get("limit"), Some(&"25".to_string())); + assert!(selected.contains("limit")); + + // 6. Build request spec + let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.method, "GET"); + assert_eq!(raw.endpoint, "/users"); + } else { + panic!("Expected Simple request spec"); + } +} + +#[test] +fn test_full_cli_workflow_with_path_params() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: get + method: GET + endpoint: /users/{id} + args: + - name: id + positional: true + required: true +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); + + let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.endpoint, "/users/123"); + } else { + panic!("Expected Simple request spec"); + } +} + +// ==================== Custom Handler Integration ==================== + +#[test] +fn test_custom_handler_workflow() { + let yaml = r#" +commands: + - name: export + subcommands: + - name: users + custom_handler: export_users + args: + - name: format + long: format + default: "json" + - name: output + long: output + default: "users.json" +"#; + let root = parse_mapping_root(yaml).unwrap(); + + // Validate handlers + let mut reg = HandlerRegistry::new(); + reg.register("export_users", |vars, _base_url, _json| { + // Verify we receive the expected variables + assert!(vars.contains_key("format")); + assert!(vars.contains_key("output")); + Ok(()) + }); + assert!(validate_handlers(&root, ®).is_ok()); + + // Build and parse CLI + let (app, path_map) = build_cli(&root, "https://api.example.com"); + let matches = app.try_get_matches_from(["cli", "export", "users", "--format", "csv"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); + + // Build request spec + let spec = build_request_from_command(None, cmd, &vars, &selected); + if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + assert_eq!(handler_name, "export_users"); + assert_eq!(handler_vars.get("format"), Some(&"csv".to_string())); + assert_eq!(handler_vars.get("output"), Some(&"users.json".to_string())); + } else { + panic!("Expected CustomHandler request spec"); + } +} + +// ==================== Template Substitution Integration ==================== + +#[test] +fn test_template_substitution_in_request() { + let yaml = r#" +commands: + - name: api + subcommands: + - name: call + method: POST + endpoint: /orgs/{org}/projects/{project} + body: '{"name": "{name}", "description": "{desc}"}' + headers: + Authorization: "Bearer {token}" + X-Org-Id: "{org}" + args: + - name: org + positional: true + required: true + - name: project + positional: true + required: true + - name: name + long: name + required: true + - name: desc + long: desc + default: "" + - name: token + long: token + required: true +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from([ + "cli", "api", "call", "acme", "myproject", + "--name", "Test Project", + "--desc", "A test project", + "--token", "secret123" + ]).unwrap(); + + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); + + let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + if let RequestSpec::Simple(raw) = spec { + assert_eq!(raw.endpoint, "/orgs/acme/projects/myproject"); + assert_eq!(raw.body, Some(r#"{"name": "Test Project", "description": "A test project"}"#.to_string())); + assert!(raw.headers.iter().any(|h| h.contains("Bearer secret123"))); + assert!(raw.headers.iter().any(|h| h.contains("X-Org-Id: acme"))); + } else { + panic!("Expected Simple request spec"); + } +} + +// ==================== Nested Groups Integration ==================== + +#[test] +fn test_deeply_nested_command_groups() { + let yaml = r#" +commands: + - name: cloud + about: "Cloud commands" + subcommands: + - name: compute + about: "Compute resources" + subcommands: + - name: instances + about: "Instance management" + subcommands: + - name: list + method: GET + endpoint: /cloud/compute/instances + - name: get + method: GET + endpoint: /cloud/compute/instances/{id} + args: + - name: id + positional: true + required: true +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + // Verify deeply nested paths exist + assert!(path_map.contains_key(&vec![ + "cloud".to_string(), + "compute".to_string(), + "instances".to_string(), + "list".to_string() + ])); + + // Parse and execute + let matches = app.try_get_matches_from([ + "cli", "cloud", "compute", "instances", "get", "vm-123" + ]).unwrap(); + + let (path, leaf) = collect_subcommand_path(&matches); + assert_eq!(path, vec!["cloud", "compute", "instances", "get"]); + + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + assert_eq!(vars.get("id"), Some(&"vm-123".to_string())); +} + +// ==================== Boolean Flag Integration ==================== + +#[test] +fn test_boolean_flag_handling() { + let yaml = r#" +commands: + - name: users + subcommands: + - name: list + method: GET + endpoint: /users?verbose={verbose} + args: + - name: verbose + long: verbose + short: v + type: bool + value: + if_set: "true" + if_not_set: "false" +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + // Without flag + let matches = app.clone().try_get_matches_from(["cli", "users", "list"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + assert_eq!(vars.get("verbose"), Some(&"false".to_string())); + + // With flag + let matches = app.try_get_matches_from(["cli", "users", "list", "--verbose"]).unwrap(); + let (_path, leaf) = collect_subcommand_path(&matches); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + assert_eq!(vars.get("verbose"), Some(&"true".to_string())); +} + +// ==================== Common Args Integration ==================== + +#[test] +fn test_common_args_defined_in_group() { + // Test common_args defined at group level, which is the supported pattern + let yaml = r#" +commands: + - name: users + common_args: + output_format: + name: output_format + long: output + short: o + default: "json" + help: "Output format" + subcommands: + - name: list + method: GET + endpoint: /users + use_common_args: + - output_format +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "users", "list", "--output", "yaml"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, _, _) = collect_vars_from_matches(cmd, leaf); + + assert_eq!(vars.get("output_format"), Some(&"yaml".to_string())); +} + +// ==================== ExecutionConfig Integration ==================== + +#[test] +fn test_execution_config_builder_pattern() { + let config = ExecutionConfig { + output: OutputFormat::Json, + conn_timeout_secs: Some(30.0), + request_timeout_secs: Some(60.0), + user_agent: "test-cli/1.0", + verbose: true, + count: Some(10), + duration_secs: 0, + concurrency: 4, + }; + + assert_eq!(config.output, OutputFormat::Json); + assert_eq!(config.conn_timeout_secs, Some(30.0)); + assert_eq!(config.concurrency, 4); + assert!(config.verbose); +} + +// ==================== Error Handling Integration ==================== + +#[test] +fn test_missing_required_handler() { + let yaml = r#" +commands: + - name: export + subcommands: + - name: data + custom_handler: nonexistent_handler +"#; + let root = parse_mapping_root(yaml).unwrap(); + let reg = HandlerRegistry::new(); + + let result = validate_handlers(&root, ®); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("nonexistent_handler")); +} + +#[test] +fn test_invalid_yaml_mapping() { + let invalid_yaml = "commands: [not: valid: yaml"; + let result = parse_mapping_root(invalid_yaml); + assert!(result.is_err()); +} + +// ==================== Scenario Command Integration ==================== + +#[test] +fn test_scenario_command_parsing() { + let yaml = r#" +commands: + - name: deploy + subcommands: + - name: app + about: "Deploy an application" + scenario: + type: sequential + steps: + - name: create_deployment + method: POST + endpoint: /deployments + body: '{"app": "{app_name}"}' + extract_response: + deployment_id: "$.id" + - name: check_status + method: GET + endpoint: /deployments/{deployment_id} + args: + - name: app_name + long: app + required: true +"#; + let root = parse_mapping_root(yaml).unwrap(); + let (app, path_map) = build_cli(&root, "https://api.example.com"); + + let matches = app.try_get_matches_from(["cli", "deploy", "app", "--app", "myapp"]).unwrap(); + let (path, leaf) = collect_subcommand_path(&matches); + let cmd = path_map.get(&path).unwrap(); + let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); + + let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + if let RequestSpec::Scenario(scenario_spec) = spec { + assert_eq!(scenario_spec.scenario.steps.len(), 2); + assert_eq!(scenario_spec.scenario.steps[0].name, "create_deployment"); + assert!(scenario_spec.vars.contains_key("app_name")); + } else { + panic!("Expected Scenario request spec"); + } +} From f300aaacbcebe15bac98582bcb15f617d44b52bb Mon Sep 17 00:00:00 2001 From: Alexis Delain Date: Tue, 16 Dec 2025 09:48:14 +0200 Subject: [PATCH 3/5] chore: minor cleanup Signed-off-by: Alexis Delain --- Cargo.toml | 1 - dummyjson-cli/Cargo.toml | 2 +- dummyjson-cli/src/main.rs | 58 ++- rclib/Cargo.toml | 7 +- rclib/src/cli.rs | 606 ++++++++++++++++++++------- rclib/src/error.rs | 100 ----- rclib/src/lib.rs | 697 +++++++++++++++++++++---------- rclib/src/mapping.rs | 72 ++-- rclib/tests/integration_tests.rs | 109 +++-- 9 files changed, 1106 insertions(+), 546 deletions(-) delete mode 100644 rclib/src/error.rs diff --git a/Cargo.toml b/Cargo.toml index a35fd84..e2fee67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ resolver = "2" # Common dependencies can be defined here anyhow = "1" clap = { version = "4.5", features = ["derive"] } -http = "1" jsonpath_lib = "0.3" openapiv3 = "1" regex = "1" diff --git a/dummyjson-cli/Cargo.toml b/dummyjson-cli/Cargo.toml index 5609476..3cc4c14 100644 --- a/dummyjson-cli/Cargo.toml +++ b/dummyjson-cli/Cargo.toml @@ -3,7 +3,7 @@ name = "dummyjson-cli" version = "0.1.0" edition = "2021" description = "A dummyjson.com mock AIP server CLI tool built with rclib" -license = "Apache 2.0" +license = "Apache-2.0" [[bin]] name = "dummyjson-cli" diff --git a/dummyjson-cli/src/main.rs b/dummyjson-cli/src/main.rs index 5246e2d..b0e9ad0 100644 --- a/dummyjson-cli/src/main.rs +++ b/dummyjson-cli/src/main.rs @@ -1,6 +1,6 @@ +use std::collections::HashMap; use std::env; use std::fs; -use std::collections::HashMap; use anyhow::{Context, Result}; @@ -27,18 +27,22 @@ fn real_main() -> Result<()> { // Load OpenAPI (for default base URL) let openapi_text = if let Some(path) = openapi_file.as_deref() { - fs::read_to_string(path).with_context(|| format!("Failed to read openapi file: {}", path))? + fs::read_to_string(path) + .with_context(|| format!("Failed to read openapi file: {}", path))? } else { EMBEDDED_OPENAPI.to_string() }; let openapi = rclib::parse_openapi(&openapi_text).context("OpenAPI parsing failed")?; - let default_base_url = openapi.servers.first().map(|s| s.url.clone()).unwrap_or_else(|| { - "https://dummyjson.com".to_string() - }); + let default_base_url = openapi + .servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_else(|| "https://dummyjson.com".to_string()); // Load mapping used to build the dynamic command tree let mapping_yaml = if let Some(path) = mapping_file.as_deref() { - fs::read_to_string(path).with_context(|| format!("Failed to read mapping file: {}", path))? + fs::read_to_string(path) + .with_context(|| format!("Failed to read mapping file: {}", path))? } else { EMBEDDED_MAPPING.to_string() }; @@ -66,7 +70,13 @@ fn real_main() -> Result<()> { // Delegate command driving to rclib let user_agent = format!("{}/{}", APP_NAME, env!("CARGO_PKG_VERSION")); - let exit_code = rclib::cli::drive_command(&mapping_root, &default_base_url, &matches, ®, &user_agent)?; + let exit_code = rclib::cli::drive_command( + &mapping_root, + &default_base_url, + &matches, + ®, + &user_agent, + )?; std::process::exit(exit_code); } @@ -77,8 +87,14 @@ fn handle_export_users( json_output: bool, ) -> Result<()> { let format = vars.get("format").map(|s| s.as_str()).unwrap_or("json"); - let output_file = vars.get("output_file").map(|s| s.as_str()).unwrap_or("users_export.json"); - let include_sensitive = vars.get("include_sensitive").map(|s| s == "true").unwrap_or(false); + let output_file = vars + .get("output_file") + .map(|s| s.as_str()) + .unwrap_or("users_export.json"); + let include_sensitive = vars + .get("include_sensitive") + .map(|s| s == "true") + .unwrap_or(false); let limit = vars.get("limit").map(|s| s.as_str()).unwrap_or("100"); let skip = vars.get("skip").map(|s| s.as_str()).unwrap_or("0"); @@ -101,10 +117,20 @@ fn handle_export_users( println!("User Export Operation"); println!("Format: {}", format); println!("Output: {}", output_file); - println!("Sensitive data: {}", if include_sensitive { "included" } else { "excluded" }); + println!( + "Sensitive data: {}", + if include_sensitive { + "included" + } else { + "excluded" + } + ); println!("Records: {} (starting from {})", limit, skip); println!("API Base: {}", base_url); - println!("\n Export would fetch from: {}/users?limit={}&skip={}", base_url, limit, skip); + println!( + "\n Export would fetch from: {}/users?limit={}&skip={}", + base_url, limit, skip + ); println!(" Would save to: {}", output_file); } @@ -117,10 +143,16 @@ fn handle_product_analytics( base_url: &str, json_output: bool, ) -> Result<()> { - let report_type = vars.get("report_type").map(|s| s.as_str()).unwrap_or("summary"); + let report_type = vars + .get("report_type") + .map(|s| s.as_str()) + .unwrap_or("summary"); let category_filter = vars.get("category_filter").map(|s| s.as_str()); let price_range = vars.get("price_range").map(|s| s.as_str()); - let output_format = vars.get("output_format").map(|s| s.as_str()).unwrap_or("table"); + let output_format = vars + .get("output_format") + .map(|s| s.as_str()) + .unwrap_or("table"); if json_output { let response = serde_json::json!({ diff --git a/rclib/Cargo.toml b/rclib/Cargo.toml index a9f0ff8..31820fa 100644 --- a/rclib/Cargo.toml +++ b/rclib/Cargo.toml @@ -3,14 +3,13 @@ name = "rclib" version = "0.1.0" edition = "2021" description = "A CLI builder library for Rust" -license = "Apache 2.0" +license = "Apache-2.0" repository = "https://github.com/your-username/rclib" [dependencies] clap = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } -http = { workspace = true } jsonpath_lib = { workspace = true } openapiv3 = { workspace = true } regex = { workspace = true } @@ -19,7 +18,3 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } once_cell = { workspace = true } uuid = { workspace = true } - -[dev-dependencies] -wiremock = "0.6" -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/rclib/src/cli.rs b/rclib/src/cli.rs index 5fcccc6..ee879c2 100644 --- a/rclib/src/cli.rs +++ b/rclib/src/cli.rs @@ -3,7 +3,10 @@ use std::collections::{HashMap, HashSet}; use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::mapping::*; -use crate::{build_request_from_command, execute_requests_loop, ExecutionConfig, OutputFormat, RequestSpec, RawRequestSpec}; +use crate::{ + build_request_from_command, execute_requests_loop, ExecutionConfig, OutputFormat, + RawRequestSpec, RequestSpec, +}; #[derive(Default)] struct TreeNode { @@ -16,7 +19,10 @@ fn leak_str>(s: S) -> &'static str { Box::leak(s.into().into_boxed_str()) } -pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command, HashMap, CommandSpec>) { +pub fn build_cli( + mapping_root: &MappingRoot, + default_base_url: &str, +) -> (Command, HashMap, CommandSpec>) { // Build a tree of commands from mapping patterns let mut root = TreeNode::default(); let mut leaf_map: HashMap, CommandSpec> = HashMap::new(); @@ -34,8 +40,15 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command for pt in &path_tokens { node = node.children.entry((*pt).to_string()).or_default(); } - node.args = if cmd.args.is_empty() { derive_args_from_pattern(&cmd.pattern) } else { cmd.args.clone() }; - leaf_map.insert(path_tokens.iter().map(|s| s.to_string()).collect(), cmd.clone()); + node.args = if cmd.args.is_empty() { + derive_args_from_pattern(&cmd.pattern) + } else { + cmd.args.clone() + }; + leaf_map.insert( + path_tokens.iter().map(|s| s.to_string()).collect(), + cmd.clone(), + ); } } MappingRoot::Hier(hier) => { @@ -54,15 +67,31 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command long: override_spec.long.clone().or_else(|| base.long.clone()), short: override_spec.short.clone().or_else(|| base.short.clone()), required: override_spec.required.or(base.required), - default: override_spec.default.clone().or_else(|| base.default.clone()), - arg_type: override_spec.arg_type.clone().or_else(|| base.arg_type.clone()), + default: override_spec + .default + .clone() + .or_else(|| base.default.clone()), + arg_type: override_spec + .arg_type + .clone() + .or_else(|| base.arg_type.clone()), value: override_spec.value.clone().or_else(|| base.value.clone()), file_upload: override_spec.file_upload || base.file_upload, - endpoint: override_spec.endpoint.clone().or_else(|| base.endpoint.clone()), + endpoint: override_spec + .endpoint + .clone() + .or_else(|| base.endpoint.clone()), method: override_spec.method.clone().or_else(|| base.method.clone()), - headers: override_spec.headers.clone().or_else(|| base.headers.clone()), + headers: override_spec + .headers + .clone() + .or_else(|| base.headers.clone()), body: override_spec.body.clone().or_else(|| base.body.clone()), - file_overrides_value_of: override_spec.file_overrides_value_of.clone().or_else(|| base.file_overrides_value_of.clone()), } + file_overrides_value_of: override_spec + .file_overrides_value_of + .clone() + .or_else(|| base.file_overrides_value_of.clone()), + } } fn walk_group( root: &mut TreeNode, @@ -74,7 +103,9 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command path.push(group.name.clone()); // Ensure group node exists and set its about let group_node = ensure_path(root, path); - if let Some(a) = &group.about { group_node.about = Some(a.clone()); } + if let Some(a) = &group.about { + group_node.about = Some(a.clone()); + } for node in &group.subcommands { match node { @@ -106,7 +137,9 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command for a in &cmd.args { if let Some(inherit_key) = &a.inherit { // First check group-level common_args, then top-level - let base = group.common_args.get(inherit_key) + let base = group + .common_args + .get(inherit_key) .or_else(|| top_level_common_args.get(inherit_key)); if let Some(base) = base { @@ -132,7 +165,9 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command } node_ref.args = resolved_args; - if let Some(a) = &cmd.about { node_ref.about = Some(a.clone()); } + if let Some(a) = &cmd.about { + node_ref.about = Some(a.clone()); + } // Create a resolved command for the leaf map let mut resolved_cmd = cmd.clone(); @@ -144,7 +179,13 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command path.pop(); } for g in &hier.commands { - walk_group(&mut root, &mut leaf_map, &mut Vec::new(), g, &hier.common_args); + walk_group( + &mut root, + &mut leaf_map, + &mut Vec::new(), + g, + &hier.common_args, + ); } } } @@ -154,27 +195,122 @@ pub fn build_cli(mapping_root: &MappingRoot, default_base_url: &str) -> (Command .about("Hyperspot REST client driven by OpenAPI and YAML mappings") .version(env!("CARGO_PKG_VERSION")) // Global options - .arg(Arg::new("log-file").long("log-file").short('l').help("Path to log file (JSON format)").num_args(1)) - .arg(Arg::new("base-url").long("base-url").short('u').help("Base API URL").num_args(1).default_value(leak_str(default_base_url.to_string()))) - .arg(Arg::new("json-output").long("json-output").short('j').help("Output in JSON format").action(ArgAction::SetTrue)) - .arg(Arg::new("verbose").long("verbose").short('v').help("Verbose output").action(ArgAction::SetTrue)) - .arg(Arg::new("conn-timeout").long("conn-timeout").help("Connection timeout in seconds").default_value("30").num_args(1)) - .arg(Arg::new("timeout").long("timeout").short('t').help("Request timeout in seconds (after connection)").default_value("300").num_args(1)) - .arg(Arg::new("openapi-file").long("openapi-file").help("Path to OpenAPI spec file").num_args(1)) - .arg(Arg::new("mapping-file").long("mapping-file").help("Path to mapping YAML file").num_args(1)) + .arg( + Arg::new("log-file") + .long("log-file") + .short('l') + .help("Path to log file (JSON format)") + .num_args(1), + ) + .arg( + Arg::new("base-url") + .long("base-url") + .short('u') + .help("Base API URL") + .num_args(1) + .default_value(leak_str(default_base_url.to_string())), + ) + .arg( + Arg::new("json-output") + .long("json-output") + .short('j') + .help("Output in JSON format") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .short('v') + .help("Verbose output") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("conn-timeout") + .long("conn-timeout") + .help("Connection timeout in seconds") + .default_value("30") + .num_args(1), + ) + .arg( + Arg::new("timeout") + .long("timeout") + .short('t') + .help("Request timeout in seconds (after connection)") + .default_value("300") + .num_args(1), + ) + .arg( + Arg::new("openapi-file") + .long("openapi-file") + .help("Path to OpenAPI spec file") + .num_args(1), + ) + .arg( + Arg::new("mapping-file") + .long("mapping-file") + .help("Path to mapping YAML file") + .num_args(1), + ) // Performance testing options .next_help_heading("Perf tests options") - .arg(Arg::new("count").long("count").short('n').help("Execute given command N times").default_value("1").value_parser(clap::value_parser!(u32))) - .arg(Arg::new("duration").long("duration").short('d').help("Execute requests for N seconds (overrides --count)").num_args(1).value_parser(clap::value_parser!(u32)).default_value("0")) - .arg(Arg::new("concurrency").long("concurrency").short('c').help("Parallel execution concurrency").num_args(1).value_parser(clap::value_parser!(u32)).default_value("1")); + .arg( + Arg::new("count") + .long("count") + .short('n') + .help("Execute given command N times") + .default_value("1") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("duration") + .long("duration") + .short('d') + .help("Execute requests for N seconds (overrides --count)") + .num_args(1) + .value_parser(clap::value_parser!(u32)) + .default_value("0"), + ) + .arg( + Arg::new("concurrency") + .long("concurrency") + .short('c') + .help("Parallel execution concurrency") + .num_args(1) + .value_parser(clap::value_parser!(u32)) + .default_value("1"), + ); // Add 'raw' command let raw_cmd = Command::new("raw") .about("Execute raw HTTP request") - .arg(Arg::new("method").long("method").help("HTTP method").required(true).num_args(1)) - .arg(Arg::new("endpoint").long("endpoint").help("Endpoint path or absolute URL").required(true).num_args(1)) - .arg(Arg::new("header").long("header").short('H').help("Header 'Key: Value' (repeatable)").num_args(1).action(ArgAction::Append)) - .arg(Arg::new("body").long("body").help("Request body").num_args(1)); + .arg( + Arg::new("method") + .long("method") + .help("HTTP method") + .required(true) + .num_args(1), + ) + .arg( + Arg::new("endpoint") + .long("endpoint") + .help("Endpoint path or absolute URL") + .required(true) + .num_args(1), + ) + .arg( + Arg::new("header") + .long("header") + .short('H') + .help("Header 'Key: Value' (repeatable)") + .num_args(1) + .action(ArgAction::Append), + ) + .arg( + Arg::new("body") + .long("body") + .help("Request body") + .num_args(1), + ); app = app.subcommand(raw_cmd); // Add hierarchical commands @@ -187,7 +323,9 @@ fn add_children_commands(mut app: Command, path: Vec, node: &TreeNode) - // Add children of current node under the app for (name, child) in &node.children { let mut cmd = Command::new(leak_str(name.clone())); - if let Some(about) = &child.about { cmd = cmd.about(leak_str(about.clone())); } + if let Some(about) = &child.about { + cmd = cmd.about(leak_str(about.clone())); + } // If this node represents a concrete command (has args), attach its args if !child.args.is_empty() { // Add args (positional first, then flags) @@ -201,7 +339,9 @@ fn add_children_commands(mut app: Command, path: Vec, node: &TreeNode) - .required(arg.required.unwrap_or(false)) .num_args(1) .index(pos_index); - if let Some(def) = &arg.default { a = a.default_value(leak_str(def.clone())); } + if let Some(def) = &arg.default { + a = a.default_value(leak_str(def.clone())); + } cmd = cmd.arg(a); pos_index += 1; } @@ -220,11 +360,19 @@ fn add_children_commands(mut app: Command, path: Vec, node: &TreeNode) - a = a.action(ArgAction::SetTrue); } else { a = a.num_args(1); - if let Some(def) = &arg.default { a = a.default_value(leak_str(def.clone())); } + if let Some(def) = &arg.default { + a = a.default_value(leak_str(def.clone())); + } } - if let Some(l) = arg.long.as_deref() { a = a.long(leak_str(l.to_string())); } else if let Some(n) = arg.name.as_deref() { a = a.long(leak_str(n.to_string())); } - if let Some(s) = arg.short.as_deref() { a = a.short(s.chars().next().unwrap()); } + if let Some(l) = arg.long.as_deref() { + a = a.long(leak_str(l.to_string())); + } else if let Some(n) = arg.name.as_deref() { + a = a.long(leak_str(n.to_string())); + } + if let Some(s) = arg.short.as_deref() { + a = a.short(s.chars().next().unwrap()); + } cmd = cmd.arg(a); } } @@ -243,7 +391,9 @@ fn add_children_commands(mut app: Command, path: Vec, node: &TreeNode) - fn add_children_subcommands(mut cmd: Command, path: Vec, node: &TreeNode) -> Command { for (name, child) in &node.children { let mut sub = Command::new(leak_str(name.clone())); - if let Some(about) = &child.about { sub = sub.about(leak_str(about.clone())); } + if let Some(about) = &child.about { + sub = sub.about(leak_str(about.clone())); + } if !child.args.is_empty() { let mut pos_index: usize = 1; for arg in &child.args { @@ -255,7 +405,9 @@ fn add_children_subcommands(mut cmd: Command, path: Vec, node: &TreeNode .required(arg.required.unwrap_or(false)) .num_args(1) .index(pos_index); - if let Some(def) = &arg.default { a = a.default_value(leak_str(def.clone())); } + if let Some(def) = &arg.default { + a = a.default_value(leak_str(def.clone())); + } sub = sub.arg(a); pos_index += 1; } @@ -274,11 +426,19 @@ fn add_children_subcommands(mut cmd: Command, path: Vec, node: &TreeNode a = a.action(ArgAction::SetTrue); } else { a = a.num_args(1); - if let Some(def) = &arg.default { a = a.default_value(leak_str(def.clone())); } + if let Some(def) = &arg.default { + a = a.default_value(leak_str(def.clone())); + } } - if let Some(l) = arg.long.as_deref() { a = a.long(leak_str(l.to_string())); } else if let Some(n) = arg.name.as_deref() { a = a.long(leak_str(n.to_string())); } - if let Some(s) = arg.short.as_deref() { a = a.short(s.chars().next().unwrap()); } + if let Some(l) = arg.long.as_deref() { + a = a.long(leak_str(l.to_string())); + } else if let Some(n) = arg.name.as_deref() { + a = a.long(leak_str(n.to_string())); + } + if let Some(s) = arg.short.as_deref() { + a = a.short(s.chars().next().unwrap()); + } sub = sub.arg(a); } } @@ -296,10 +456,14 @@ pub fn collect_subcommand_path(matches: &ArgMatches) -> (Vec, &ArgMatche let mut path: Vec = Vec::new(); let mut current = matches; while let Some((name, sub_m)) = current.subcommand() { - if name == "raw" { break; } + if name == "raw" { + break; + } path.push(name.to_string()); current = sub_m; - if sub_m.subcommand().is_none() { break; } + if sub_m.subcommand().is_none() { + break; + } } (path, current) } @@ -320,10 +484,19 @@ pub fn print_manual_help(path: &[String], cmd: &CommandSpec) { // OPTIONS println!("\nOPTIONS:"); for arg in &cmd.args { - let long = arg.long.clone().or_else(|| arg.name.clone()).unwrap_or_default(); + let long = arg + .long + .clone() + .or_else(|| arg.name.clone()) + .unwrap_or_default(); let help = arg.help.clone().unwrap_or_default(); let required = arg.required.unwrap_or(false); - println!(" --{} value {}{}", long, help, if required { " (required)" } else { "" }); + println!( + " --{} value {}{}", + long, + help, + if required { " (required)" } else { "" } + ); } println!(" --help, -h show help"); } @@ -344,7 +517,8 @@ pub fn pre_scan_value(args: &[String], key: &str) -> Option { // Runtime helpers // ===================== -pub type CustomHandlerFn = dyn Fn(&HashMap, &str, bool) -> anyhow::Result<()> + Send + Sync + 'static; +pub type CustomHandlerFn = + dyn Fn(&HashMap, &str, bool) -> anyhow::Result<()> + Send + Sync + 'static; #[derive(Default)] pub struct HandlerRegistry { @@ -353,14 +527,18 @@ pub struct HandlerRegistry { impl HandlerRegistry { #[must_use] - pub fn new() -> Self { Self::default() } + pub fn new() -> Self { + Self::default() + } pub fn register(&mut self, name: &str, f: F) where F: Fn(&HashMap, &str, bool) -> anyhow::Result<()> + Send + Sync + 'static, { self.handlers.insert(name.to_string(), Box::new(f)); } - pub fn get(&self, name: &str) -> Option<&CustomHandlerFn> { self.handlers.get(name).map(AsRef::as_ref) } + pub fn get(&self, name: &str) -> Option<&CustomHandlerFn> { + self.handlers.get(name).map(AsRef::as_ref) + } } pub fn validate_handlers(root: &MappingRoot, registry: &HandlerRegistry) -> anyhow::Result<()> { @@ -369,7 +547,9 @@ pub fn validate_handlers(root: &MappingRoot, registry: &HandlerRegistry) -> anyh MappingRoot::Flat(flat) => { for cmd in &flat.commands { if let Some(h) = &cmd.custom_handler { - if !registry.handlers.contains_key(h) { missing.push(h.clone()); } + if !registry.handlers.contains_key(h) { + missing.push(h.clone()); + } } } } @@ -380,53 +560,111 @@ pub fn validate_handlers(root: &MappingRoot, registry: &HandlerRegistry) -> anyh CommandNode::Group(g) => walk(g, reg, acc), CommandNode::Command(c) => { if let Some(h) = &c.custom_handler { - if !reg.handlers.contains_key(h) { acc.push(h.clone()); } + if !reg.handlers.contains_key(h) { + acc.push(h.clone()); + } } } } } } - for g in &hier.commands { walk(g, registry, &mut missing); } + for g in &hier.commands { + walk(g, registry, &mut missing); + } } } - if missing.is_empty() { Ok(()) } else { Err(anyhow::anyhow!("Missing custom handlers: {}", missing.join(", "))) } + if missing.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "Missing custom handlers: {}", + missing.join(", ") + )) + } } fn parse_timeout(matches: &ArgMatches, arg_name: &str) -> Option { - matches.get_one::(arg_name).and_then(|s| s.parse::().ok()).filter(|v| *v >= 0.0) + matches + .get_one::(arg_name) + .and_then(|s| s.parse::().ok()) + .filter(|v| *v >= 0.0) } -pub fn collect_vars_from_matches(cmd: &CommandSpec, leaf: &ArgMatches) -> (HashMap, HashSet, bool) { - let arg_specs: Vec = if cmd.args.is_empty() { derive_args_from_pattern(&cmd.pattern) } else { cmd.args.clone() }; +pub fn collect_vars_from_matches( + cmd: &CommandSpec, + leaf: &ArgMatches, +) -> (HashMap, HashSet, bool) { + let arg_specs: Vec = if cmd.args.is_empty() { + derive_args_from_pattern(&cmd.pattern) + } else { + cmd.args.clone() + }; let mut vars: HashMap = HashMap::new(); let mut selected: HashSet = HashSet::new(); let mut missing_required = false; for arg in &arg_specs { - let name = arg.long.clone().or_else(|| arg.name.clone()).unwrap_or_default(); - if name.is_empty() { continue; } + let name = arg + .long + .clone() + .or_else(|| arg.name.clone()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } if arg.arg_type.as_deref() == Some("bool") { let is_set = leaf.get_flag(&name); if let Some(var_name) = arg.name.clone() { - if is_set { selected.insert(var_name.clone()); } + if is_set { + selected.insert(var_name.clone()); + } if let Some(cond) = &arg.value { let val = match cond { ConditionalValue::Mapping { if_set, if_not_set } => { - if is_set { if_set.clone() } else { if_not_set.clone() } + if is_set { + if_set.clone() + } else { + if_not_set.clone() + } } ConditionalValue::Sequence(entries) => { - if is_set { entries.iter().find_map(|e| e.if_set.clone()) } else { entries.iter().find_map(|e| e.if_not_set.clone()) } + if is_set { + entries.iter().find_map(|e| e.if_set.clone()) + } else { + entries.iter().find_map(|e| e.if_not_set.clone()) + } + } + } + .unwrap_or_else(|| { + if is_set { + "true".to_string() + } else { + "false".to_string() } - }.unwrap_or_else(|| if is_set { "true".to_string() } else { "false".to_string() }); + }); vars.insert(var_name, val); } else { - vars.insert(var_name, if is_set { "true".to_string() } else { "false".to_string() }); + vars.insert( + var_name, + if is_set { + "true".to_string() + } else { + "false".to_string() + }, + ); } } } else if let Some(val) = leaf.get_one::(&name) { - if let Some(var_name) = arg.name.clone() { vars.insert(var_name.clone(), val.clone()); selected.insert(var_name); } + if let Some(var_name) = arg.name.clone() { + vars.insert(var_name.clone(), val.clone()); + selected.insert(var_name); + } } else if let Some(def) = &arg.default { - if let Some(var_name) = arg.name.clone() { vars.insert(var_name, def.clone()); } - } else if arg.required.unwrap_or(false) { missing_required = true; } + if let Some(var_name) = arg.name.clone() { + vars.insert(var_name, def.clone()); + } + } else if arg.required.unwrap_or(false) { + missing_required = true; + } } (vars, selected, missing_required) } @@ -438,12 +676,19 @@ pub fn drive_command( handlers: &HandlerRegistry, user_agent: &str, ) -> anyhow::Result { - let base_url = matches.get_one::("base-url").cloned().unwrap_or_else(|| default_base_url.to_string()); + let base_url = matches + .get_one::("base-url") + .cloned() + .unwrap_or_else(|| default_base_url.to_string()); let json_output = matches.get_flag("json-output"); let verbose = matches.get_flag("verbose"); let config = ExecutionConfig { - output: if json_output { OutputFormat::Json } else { OutputFormat::Human }, + output: if json_output { + OutputFormat::Json + } else { + OutputFormat::Human + }, conn_timeout_secs: parse_timeout(matches, "conn-timeout"), request_timeout_secs: parse_timeout(matches, "timeout"), user_agent, @@ -455,11 +700,29 @@ pub fn drive_command( // RAW subcommand handled here if let Some(("raw", raw_m)) = matches.subcommand() { - let method = raw_m.get_one::("method").cloned().unwrap_or_else(|| "GET".to_string()); - let endpoint = raw_m.get_one::("endpoint").cloned().unwrap_or_default(); - let headers: Vec = raw_m.get_many::("header").map(|v| v.cloned().collect()).unwrap_or_default(); + let method = raw_m + .get_one::("method") + .cloned() + .unwrap_or_else(|| "GET".to_string()); + let endpoint = raw_m + .get_one::("endpoint") + .cloned() + .unwrap_or_default(); + let headers: Vec = raw_m + .get_many::("header") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); let body = raw_m.get_one::("body").cloned(); - let raw_spec = RawRequestSpec { base_url: Some(base_url.clone()), method, endpoint, headers, body, multipart: false, file_fields: HashMap::new(), table_view: None }; + let raw_spec = RawRequestSpec { + base_url: Some(base_url.clone()), + method, + endpoint, + headers, + body, + multipart: false, + file_fields: HashMap::new(), + table_view: None, + }; return execute_requests_loop(&RequestSpec::Simple(raw_spec), &config); } @@ -473,11 +736,16 @@ pub fn drive_command( if let Some(cmd) = path_map.get(&path) { let (vars, selected, missing_required) = collect_vars_from_matches(cmd, leaf); - if missing_required { print_manual_help(&path, cmd); return Ok(2); } + if missing_required { + print_manual_help(&path, cmd); + return Ok(2); + } let spec = build_request_from_command(Some(base_url.clone()), cmd, &vars, &selected); match &spec { RequestSpec::CustomHandler { handler_name, vars } => { - let h = handlers.get(handler_name).ok_or_else(|| anyhow::anyhow!("No handler registered for {}", handler_name))?; + let h = handlers + .get(handler_name) + .ok_or_else(|| anyhow::anyhow!("No handler registered for {}", handler_name))?; h(vars, &base_url, json_output)?; Ok(0) } @@ -487,8 +755,15 @@ pub fn drive_command( // Intermediate path: print nested help let mut cmd = app2; for name in &path { - let next_opt = cmd.get_subcommands().find(|c| c.get_name() == name).cloned(); - if let Some(next_cmd) = next_opt { cmd = next_cmd; } else { break; } + let next_opt = cmd + .get_subcommands() + .find(|c| c.get_name() == name) + .cloned(); + if let Some(next_cmd) = next_opt { + cmd = next_cmd; + } else { + break; + } } let _ = cmd.clone().print_help(); println!(); @@ -726,14 +1001,12 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("limit".to_string()), - default: Some("10".to_string()), - required: Some(false), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("limit".to_string()), + default: Some("10".to_string()), + required: Some(false), + ..Default::default() + }], use_common_args: vec![], }; @@ -772,15 +1045,13 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("limit".to_string()), - long: Some("limit".to_string()), - default: Some("10".to_string()), - required: Some(false), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("limit".to_string()), + long: Some("limit".to_string()), + default: Some("10".to_string()), + required: Some(false), + ..Default::default() + }], use_common_args: vec![], }; @@ -798,7 +1069,9 @@ commands: "#; let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "users", "list", "--limit", "50"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "list", "--limit", "50"]) + .unwrap(); let (_, leaf) = collect_subcommand_path(&matches); let (vars, selected, _) = collect_vars_from_matches(&cmd, leaf); @@ -851,7 +1124,9 @@ commands: let (app, _) = build_cli(&root, "https://api.example.com"); // Should be able to parse positional arg - let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "get", "123"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); assert_eq!(path, vec!["users", "get"]); assert_eq!(leaf.get_one::("id"), Some(&"123".to_string())); @@ -876,7 +1151,9 @@ commands: let (app, _) = build_cli(&root, "https://api.example.com"); // Should be able to use short arg - let matches = app.try_get_matches_from(["cli", "users", "list", "-l", "25"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "list", "-l", "25"]) + .unwrap(); let (_, leaf) = collect_subcommand_path(&matches); assert_eq!(leaf.get_one::("limit"), Some(&"25".to_string())); } @@ -922,15 +1199,13 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("limit".to_string()), - long: Some("limit".to_string()), - help: Some("Maximum results".to_string()), - required: Some(false), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("limit".to_string()), + long: Some("limit".to_string()), + help: Some("Maximum results".to_string()), + required: Some(false), + ..Default::default() + }], use_common_args: vec![], }; // Just verify it doesn't panic @@ -972,15 +1247,13 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("id".to_string()), - long: Some("id".to_string()), - help: Some("User ID".to_string()), - required: Some(true), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("id".to_string()), + long: Some("id".to_string()), + help: Some("User ID".to_string()), + required: Some(true), + ..Default::default() + }], use_common_args: vec![], }; // Just verify it doesn't panic @@ -1001,7 +1274,9 @@ commands: "#; let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--timeout", "60", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--timeout", "60", "test", "cmd"]) + .unwrap(); let timeout = parse_timeout(&matches, "timeout"); assert_eq!(timeout, Some(60.0)); } @@ -1018,7 +1293,9 @@ commands: "#; let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--timeout", "0", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--timeout", "0", "test", "cmd"]) + .unwrap(); let timeout = parse_timeout(&matches, "timeout"); assert_eq!(timeout, Some(0.0)); // Zero is valid } @@ -1040,14 +1317,12 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("id".to_string()), - long: Some("id".to_string()), - required: Some(true), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("id".to_string()), + long: Some("id".to_string()), + required: Some(true), + ..Default::default() + }], use_common_args: vec![], }; @@ -1086,18 +1361,16 @@ commands: scenario: None, multipart: false, custom_handler: None, - args: vec![ - ArgSpec { - name: Some("verbose".to_string()), - long: Some("verbose".to_string()), - arg_type: Some("bool".to_string()), - value: Some(ConditionalValue::Mapping { - if_set: Some("true".to_string()), - if_not_set: Some("false".to_string()), - }), - ..Default::default() - }, - ], + args: vec![ArgSpec { + name: Some("verbose".to_string()), + long: Some("verbose".to_string()), + arg_type: Some("bool".to_string()), + value: Some(ConditionalValue::Mapping { + if_set: Some("true".to_string()), + if_not_set: Some("false".to_string()), + }), + ..Default::default() + }], use_common_args: vec![], }; @@ -1118,9 +1391,12 @@ commands: "#; let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - + // Test with flag set - let matches = app.clone().try_get_matches_from(["cli", "users", "list", "--verbose"]).unwrap(); + let matches = app + .clone() + .try_get_matches_from(["cli", "users", "list", "--verbose"]) + .unwrap(); let (_, leaf) = collect_subcommand_path(&matches); let (vars, _, _) = collect_vars_from_matches(&cmd, leaf); assert_eq!(vars.get("verbose"), Some(&"true".to_string())); @@ -1161,7 +1437,9 @@ commands: let flat = parse_flat_spec(yaml).unwrap(); let root = MappingRoot::Flat(flat); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "get", "123"]) + .unwrap(); let (_, leaf) = collect_subcommand_path(&matches); let (vars, _, _) = collect_vars_from_matches(&cmd, leaf); @@ -1226,7 +1504,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "api", "call", "--format", "xml"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "api", "call", "--format", "xml"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, _, _) = collect_vars_from_matches(cmd, leaf); @@ -1371,7 +1651,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "api", "call", "--verbose"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "api", "call", "--verbose"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, _, _) = collect_vars_from_matches(cmd, leaf); @@ -1433,18 +1715,28 @@ commands: let (app, _) = build_cli(&root, "https://api.example.com"); // Parse raw command - collect_subcommand_path stops at "raw" - let matches = app.try_get_matches_from([ - "cli", "raw", - "--method", "POST", - "--endpoint", "/api/test", - "--header", "Content-Type: application/json", - "--body", r#"{"key": "value"}"# - ]).unwrap(); + let matches = app + .try_get_matches_from([ + "cli", + "raw", + "--method", + "POST", + "--endpoint", + "/api/test", + "--header", + "Content-Type: application/json", + "--body", + r#"{"key": "value"}"#, + ]) + .unwrap(); // raw subcommand is handled specially - verify we can get the subcommand if let Some(("raw", raw_m)) = matches.subcommand() { assert_eq!(raw_m.get_one::("method"), Some(&"POST".to_string())); - assert_eq!(raw_m.get_one::("endpoint"), Some(&"/api/test".to_string())); + assert_eq!( + raw_m.get_one::("endpoint"), + Some(&"/api/test".to_string()) + ); } else { panic!("Expected raw subcommand"); } @@ -1465,7 +1757,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--conn-timeout", "45", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--conn-timeout", "45", "test", "cmd"]) + .unwrap(); let timeout = parse_timeout(&matches, "conn-timeout"); assert_eq!(timeout, Some(45.0)); } @@ -1483,7 +1777,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--json-output", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--json-output", "test", "cmd"]) + .unwrap(); assert!(matches.get_flag("json-output")); } @@ -1500,7 +1796,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "-v", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "-v", "test", "cmd"]) + .unwrap(); assert!(matches.get_flag("verbose")); } @@ -1519,7 +1817,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--count", "100", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--count", "100", "test", "cmd"]) + .unwrap(); assert_eq!(matches.get_one::("count"), Some(&100)); } @@ -1536,7 +1836,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--duration", "30", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--duration", "30", "test", "cmd"]) + .unwrap(); assert_eq!(matches.get_one::("duration"), Some(&30)); } @@ -1553,7 +1855,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, _) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "--concurrency", "4", "test", "cmd"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "--concurrency", "4", "test", "cmd"]) + .unwrap(); assert_eq!(matches.get_one::("concurrency"), Some(&4)); } } diff --git a/rclib/src/error.rs b/rclib/src/error.rs deleted file mode 100644 index 8d50f3e..0000000 --- a/rclib/src/error.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Error handling for rclib. - -use std::fmt; - -/// The main error type for rclib operations. -#[derive(Debug)] -pub enum Error { - /// Generic error with a message. - Generic(String), - /// IO error wrapper. - Io(std::io::Error), - /// CLI parsing error. - CliError(String), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Generic(msg) => write!(f, "{}", msg), - Error::Io(err) => write!(f, "IO error: {}", err), - Error::CliError(msg) => write!(f, "CLI error: {}", msg), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(err: std::io::Error) -> Self { - Error::Io(err) - } -} - -/// A Result type alias for rclib operations. -pub type Result = std::result::Result; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_generic_display() { - let err = Error::Generic("test error".to_string()); - assert_eq!(format!("{}", err), "test error"); - } - - #[test] - fn test_error_io_display() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); - let err = Error::Io(io_err); - assert!(format!("{}", err).contains("IO error")); - } - - #[test] - fn test_error_cli_display() { - let err = Error::CliError("invalid argument".to_string()); - assert_eq!(format!("{}", err), "CLI error: invalid argument"); - } - - #[test] - fn test_error_source_io() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); - let err = Error::Io(io_err); - assert!(std::error::Error::source(&err).is_some()); - } - - #[test] - fn test_error_source_generic() { - let err = Error::Generic("test".to_string()); - assert!(std::error::Error::source(&err).is_none()); - } - - #[test] - fn test_error_source_cli() { - let err = Error::CliError("test".to_string()); - assert!(std::error::Error::source(&err).is_none()); - } - - #[test] - fn test_error_from_io() { - let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"); - let err: Error = io_err.into(); - assert!(matches!(err, Error::Io(_))); - } - - #[test] - fn test_error_debug() { - let err = Error::Generic("debug test".to_string()); - let debug_str = format!("{:?}", err); - assert!(debug_str.contains("Generic")); - assert!(debug_str.contains("debug test")); - } -} diff --git a/rclib/src/lib.rs b/rclib/src/lib.rs index 7defd49..83eea16 100644 --- a/rclib/src/lib.rs +++ b/rclib/src/lib.rs @@ -1,24 +1,24 @@ -use std::collections::HashMap; -use std::collections::HashSet; -// use std::fmt::Write as _; -use std::sync::{Arc, atomic::{AtomicU32, AtomicBool, Ordering}}; +use std::collections::{HashMap, HashSet}; use std::sync::mpsc; +use std::sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + Arc, +}; use std::thread; use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; -use http::HeaderMap; use jsonpath_lib as jsonpath; use openapiv3::OpenAPI; use regex::Regex; use reqwest::blocking::{Client, ClientBuilder, Response}; -use reqwest::header::{HeaderName, HeaderValue}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Method; use serde_json::Value; use uuid::Uuid; -pub mod mapping; pub mod cli; +pub mod mapping; // ===================== // Public API @@ -40,14 +40,17 @@ pub struct RawRequestSpec { pub body: Option, pub multipart: bool, pub file_fields: HashMap, // field_name -> file_path - pub table_view: Option>, // optional column hints for array responses + pub table_view: Option>, // optional column hints for array responses } #[derive(Debug, Clone)] pub enum RequestSpec { Simple(RawRequestSpec), Scenario(ScenarioSpec), - CustomHandler { handler_name: String, vars: HashMap }, + CustomHandler { + handler_name: String, + vars: HashMap, + }, } #[derive(Debug, Clone)] @@ -104,8 +107,14 @@ fn apply_file_overrides(args: &[mapping::ArgSpec], vars: &mut HashMap = cmd .headers .iter() @@ -177,19 +196,28 @@ pub fn build_request_from_command( for a in &cmd.args { if let Some(arg_name) = a.name.as_ref() { if selected_args.contains(arg_name) { - if let Some(ep) = &a.endpoint { endpoint = substitute_template(ep, &vars_with_builtins); } - if let Some(m) = &a.method { method = m.clone(); } + if let Some(ep) = &a.endpoint { + endpoint = substitute_template(ep, &vars_with_builtins); + } + if let Some(m) = &a.method { + method = m.clone(); + } if let Some(hdrs) = &a.headers { for (k, v) in hdrs { headers_map.insert(k.clone(), substitute_template(v, &vars_with_builtins)); } } - if let Some(b) = &a.body { body = Some(substitute_template(b, &vars_with_builtins)); } + if let Some(b) = &a.body { + body = Some(substitute_template(b, &vars_with_builtins)); + } } } } - let headers: Vec = headers_map.into_iter().map(|(k, v)| format!("{}: {}", k, v)).collect(); + let headers: Vec = headers_map + .into_iter() + .map(|(k, v)| format!("{}: {}", k, v)) + .collect(); // Handle multipart uploads let mut file_fields: HashMap = HashMap::new(); @@ -218,7 +246,11 @@ pub fn build_request_from_command( } /// Execute a request and print output according to format. -pub fn execute_request(spec: &RawRequestSpec, output: OutputFormat, user_agent: &str) -> Result { +pub fn execute_request( + spec: &RawRequestSpec, + output: OutputFormat, + user_agent: &str, +) -> Result { execute_request_with_timeout(spec, output, None, None, user_agent, &HashSet::new(), false) } @@ -232,15 +264,28 @@ pub fn execute_request_spec( verbose: bool, ) -> Result { match spec { - RequestSpec::Simple(raw_spec) => { - execute_request_with_timeout(raw_spec, output, conn_timeout_secs, request_timeout_secs, user_agent, &HashSet::new(), verbose) - } - RequestSpec::Scenario(scenario_spec) => { - execute_scenario(scenario_spec, output, conn_timeout_secs, request_timeout_secs, user_agent, verbose) - } + RequestSpec::Simple(raw_spec) => execute_request_with_timeout( + raw_spec, + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + &HashSet::new(), + verbose, + ), + RequestSpec::Scenario(scenario_spec) => execute_scenario( + scenario_spec, + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + verbose, + ), RequestSpec::CustomHandler { .. } => { // Custom handlers should not reach this function - they are handled by the calling application - bail!("Custom handlers should be handled by the calling application, not by the library"); + bail!( + "Custom handlers should be handled by the calling application, not by the library" + ); } } } @@ -282,15 +327,12 @@ fn execute_worker_request( Err(_e) => ExecutionResult { duration, is_success: false, - } + }, } } /// Execute a request with count, duration, and concurrency control -pub fn execute_requests_loop( - spec: &RequestSpec, - config: &ExecutionConfig<'_>, -) -> Result { +pub fn execute_requests_loop(spec: &RequestSpec, config: &ExecutionConfig<'_>) -> Result { let ExecutionConfig { output, conn_timeout_secs, @@ -309,7 +351,16 @@ pub fn execute_requests_loop( } else { match count { Some(c) if c > 1 => Some(c), - _ => return execute_request_spec(spec, output, conn_timeout_secs, request_timeout_secs, user_agent, verbose), + _ => { + return execute_request_spec( + spec, + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + verbose, + ) + } } }; @@ -321,16 +372,31 @@ pub fn execute_requests_loop( if use_duration { eprintln!("Warning: Custom handlers cannot be executed with duration. Ignoring --duration option."); } else { - eprintln!("Warning: Custom handlers cannot be executed in parallel. Ignoring --count option."); + eprintln!( + "Warning: Custom handlers cannot be executed in parallel. Ignoring --count option." + ); } - return execute_request_spec(spec, output, conn_timeout_secs, request_timeout_secs, user_agent, verbose); + return execute_request_spec( + spec, + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + verbose, + ); } if verbose { if use_duration { - eprintln!("Executing requests for {} seconds with concurrency {}", duration_secs, concurrency); + eprintln!( + "Executing requests for {} seconds with concurrency {}", + duration_secs, concurrency + ); } else if let Some(count) = target_count { - eprintln!("Executing {} requests with concurrency {}", count, concurrency); + eprintln!( + "Executing {} requests with concurrency {}", + count, concurrency + ); } } @@ -365,7 +431,9 @@ pub fn execute_requests_loop( } } else { // Count mode: simple check before incrementing - if executed_count_clone.load(Ordering::Relaxed) >= target_count_clone.unwrap_or(0) { + if executed_count_clone.load(Ordering::Relaxed) + >= target_count_clone.unwrap_or(0) + { break; } } @@ -379,7 +447,11 @@ pub fn execute_requests_loop( } // Execute the request - let worker_output = if verbose_clone { OutputFormat::Json } else { OutputFormat::Quiet }; + let worker_output = if verbose_clone { + OutputFormat::Json + } else { + OutputFormat::Quiet + }; let result = execute_worker_request( &spec_clone, worker_output, @@ -423,8 +495,10 @@ pub fn execute_requests_loop( total_response_duration += result.duration; // Update min/max response times - min_response_time = Some(min_response_time.map_or(result.duration, |min| min.min(result.duration))); - max_response_time = Some(max_response_time.map_or(result.duration, |max| max.max(result.duration))); + min_response_time = + Some(min_response_time.map_or(result.duration, |min| min.min(result.duration))); + max_response_time = + Some(max_response_time.map_or(result.duration, |max| max.max(result.duration))); if result.is_success { success_count += 1; @@ -445,26 +519,50 @@ pub fn execute_requests_loop( if final_executed_count > 1 && !matches!(output, OutputFormat::Json) { println!("======= Execution Summary ======="); println!("Concurrency: {}", concurrency); - println!("Total execution time: {:.3}s", overall_duration.as_secs_f64()); + println!( + "Total execution time: {:.3}s", + overall_duration.as_secs_f64() + ); println!("Executed requests: {}", final_executed_count); if error_count > 0 { - println!("Successful requests: {} ({:.0}%)", success_count, (success_count as f64 / final_executed_count as f64) * 100.0); - println!("Failed requests: {} ({:.0}%)", error_count, (error_count as f64 / final_executed_count as f64) * 100.0); + println!( + "Successful requests: {} ({:.0}%)", + success_count, + (success_count as f64 / final_executed_count as f64) * 100.0 + ); + println!( + "Failed requests: {} ({:.0}%)", + error_count, + (error_count as f64 / final_executed_count as f64) * 100.0 + ); } else { println!("Successful requests: {}", success_count); println!("Failed requests: {}", error_count); } if final_executed_count > 0 { - println!("Average response time: {:.3}s (min: {:.3}s, max: {:.3}s)", + println!( + "Average response time: {:.3}s (min: {:.3}s, max: {:.3}s)", total_response_duration.as_secs_f64() / final_executed_count as f64, - min_response_time.unwrap_or(Duration::from_millis(0)).as_secs_f64(), - max_response_time.unwrap_or(Duration::from_millis(0)).as_secs_f64()); - println!("Requests per second: {:.2}", final_executed_count as f64 / overall_duration.as_secs_f64()); + min_response_time + .unwrap_or(Duration::from_millis(0)) + .as_secs_f64(), + max_response_time + .unwrap_or(Duration::from_millis(0)) + .as_secs_f64() + ); + println!( + "Requests per second: {:.2}", + final_executed_count as f64 / overall_duration.as_secs_f64() + ); } } // Return appropriate exit code - if error_count > 0 { Ok(1) } else { Ok(0) } + if error_count > 0 { + Ok(1) + } else { + Ok(0) + } } /// Execute a request with optional connection and request timeout seconds. @@ -527,16 +625,25 @@ pub fn execute_request_with_timeout( eprintln!("-> {} {}", spec.method, full_url); if !spec.headers.is_empty() { eprintln!("-> Headers:"); - for h in &spec.headers { eprintln!(" {}", h); } + for h in &spec.headers { + eprintln!(" {}", h); + } + } + if let Some(b) = &spec.body { + eprintln!("-> Body: {}", b); } - if let Some(b) = &spec.body { eprintln!("-> Body: {}", b); } } let started = std::time::Instant::now(); let resp = req.send().context("HTTP request failed")?; let elapsed_ms = started.elapsed().as_millis(); if verbose { - eprintln!("<- {} {} ({} ms)", resp.status().as_u16(), full_url, elapsed_ms); + eprintln!( + "<- {} {} ({} ms)", + resp.status().as_u16(), + full_url, + elapsed_ms + ); } output_response(resp, output, spec.table_view.as_ref()) } @@ -553,11 +660,20 @@ pub fn execute_scenario( let mut variables = scenario_spec.vars.clone(); match scenario_spec.scenario.scenario_type.as_str() { - "job_with_polling" => { - execute_job_with_polling_scenario(scenario_spec, &mut variables, output, conn_timeout_secs, request_timeout_secs, user_agent, verbose) - } + "job_with_polling" => execute_job_with_polling_scenario( + scenario_spec, + &mut variables, + output, + conn_timeout_secs, + request_timeout_secs, + user_agent, + verbose, + ), _ => { - bail!("Unsupported scenario type: {}", scenario_spec.scenario.scenario_type) + bail!( + "Unsupported scenario type: {}", + scenario_spec.scenario.scenario_type + ) } } } @@ -582,17 +698,36 @@ fn execute_job_with_polling_scenario( bail!("First step must be named 'schedule_job'"); } - let schedule_spec = build_raw_spec_from_step(&scenario_spec.base_url, schedule_step, variables)?; - if verbose { eprintln!("-> {} {}", schedule_spec.method, build_url(&schedule_spec.base_url, &schedule_spec.endpoint)?); } - let schedule_response = execute_single_request(&schedule_spec, conn_timeout_secs, request_timeout_secs, user_agent)?; + let schedule_spec = + build_raw_spec_from_step(&scenario_spec.base_url, schedule_step, variables)?; + if verbose { + eprintln!( + "-> {} {}", + schedule_spec.method, + build_url(&schedule_spec.base_url, &schedule_spec.endpoint)? + ); + } + let schedule_response = execute_single_request( + &schedule_spec, + conn_timeout_secs, + request_timeout_secs, + user_agent, + )?; // Extract response variables - extract_response_variables(&schedule_response, &schedule_step.extract_response, variables)?; + extract_response_variables( + &schedule_response, + &schedule_step.extract_response, + variables, + )?; if output == OutputFormat::Json { println!("Step 1 (schedule_job) completed"); } else { - let job_id = variables.get("job_id").map(|s| s.as_str()).unwrap_or("unknown"); + let job_id = variables + .get("job_id") + .map(|s| s.as_str()) + .unwrap_or("unknown"); println!("Job scheduled with ID: {}", job_id); println!("Waiting for job to complete..."); } @@ -603,7 +738,9 @@ fn execute_job_with_polling_scenario( bail!("Second step must be named 'poll_job'"); } - let polling_config = poll_step.polling.as_ref() + let polling_config = poll_step + .polling + .as_ref() .context("poll_job step must have polling configuration")?; let start_time = Instant::now(); @@ -611,12 +748,26 @@ fn execute_job_with_polling_scenario( loop { if start_time.elapsed() > timeout_duration { - bail!("Polling timeout after {} seconds", polling_config.timeout_seconds); + bail!( + "Polling timeout after {} seconds", + polling_config.timeout_seconds + ); } let poll_spec = build_raw_spec_from_step(&scenario_spec.base_url, poll_step, variables)?; - if verbose { eprintln!("-> {} {}", poll_spec.method, build_url(&poll_spec.base_url, &poll_spec.endpoint)?); } - let poll_response = execute_single_request(&poll_spec, conn_timeout_secs, request_timeout_secs, user_agent)?; + if verbose { + eprintln!( + "-> {} {}", + poll_spec.method, + build_url(&poll_spec.base_url, &poll_spec.endpoint)? + ); + } + let poll_response = execute_single_request( + &poll_spec, + conn_timeout_secs, + request_timeout_secs, + user_agent, + )?; // Parse response to check completion condition let response_json: Value = serde_json::from_str(&poll_response) @@ -684,8 +835,12 @@ fn build_raw_spec_from_step( variables: &HashMap, ) -> Result { let endpoint = substitute_template(&step.endpoint, variables); - let body = step.body.as_ref().map(|b| substitute_template(b, variables)); - let headers: Vec = step.headers + let body = step + .body + .as_ref() + .map(|b| substitute_template(b, variables)); + let headers: Vec = step + .headers .iter() .map(|(k, v)| format!("{}: {}", k, substitute_template(v, variables))) .collect(); @@ -760,7 +915,11 @@ fn extract_response_variables( if let Some(value) = extract_jsonpath_value(&response_json, jsonpath_expr) { variables.insert(var_name.clone(), value); } else { - bail!("Failed to extract variable '{}' using JSONPath '{}'", var_name, jsonpath_expr); + bail!( + "Failed to extract variable '{}' using JSONPath '{}'", + var_name, + jsonpath_expr + ); } } @@ -836,7 +995,11 @@ fn parse_headers(raw_headers: &[String]) -> Result { Ok(map) } -fn output_response(resp: Response, output: OutputFormat, table_view: Option<&Vec>) -> Result { +fn output_response( + resp: Response, + output: OutputFormat, + table_view: Option<&Vec>, +) -> Result { let status = resp.status(); let text = resp.text().unwrap_or_default(); @@ -882,10 +1045,14 @@ fn print_human_readable(v: &serde_json::Value, table_view: Option<&Vec>) } } scalar_entries.sort_by_key(|(k, _)| *k); - let width = scalar_entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let width = scalar_entries + .iter() + .map(|(k, _)| k.len()) + .max() + .unwrap_or(0); for (k, val) in scalar_entries { let s = scalar_to_string(val); - println!("{key:width$}: {val}", key=k, width=width, val=s); + println!("{key:width$}: {val}", key = k, width = width, val = s); } // Then print arrays as tables for (k, val) in array_entries { @@ -906,7 +1073,10 @@ fn print_human_readable(v: &serde_json::Value, table_view: Option<&Vec>) } fn print_array_table(arr: &Vec, table_view: Option<&Vec>) { - if arr.is_empty() { println!("(empty)"); return; } + if arr.is_empty() { + println!("(empty)"); + return; + } // Parse column specifications (path and optional modifier) let col_specs: Vec = if let Some(cols) = table_view { @@ -920,11 +1090,15 @@ fn print_array_table(arr: &Vec, table_view: Option<&Vec { for inner_k in inner.keys() { let path = format!("{}.{}", k, inner_k); - if !derived.contains(&path) { derived.push(path); } + if !derived.contains(&path) { + derived.push(path); + } } } _ => { - if !derived.contains(k) { derived.push(k.clone()); } + if !derived.contains(k) { + derived.push(k.clone()); + } } } } @@ -940,7 +1114,10 @@ fn print_array_table(arr: &Vec, table_view: Option<&Vec = col_specs.iter().map(|c| humanize_column_label_with_modifier(&c.path, &c.modifier)).collect(); + let header_labels: Vec = col_specs + .iter() + .map(|c| humanize_column_label_with_modifier(&c.path, &c.modifier)) + .collect(); let header_lines: Vec> = header_labels .iter() .map(|lbl| lbl.split_whitespace().map(|s| s.to_string()).collect()) @@ -957,7 +1134,9 @@ fn print_array_table(arr: &Vec, table_view: Option<&Vec widths[idx] { widths[idx] = cell.len(); } + if cell.len() > widths[idx] { + widths[idx] = cell.len(); + } } } } @@ -974,8 +1153,12 @@ fn print_array_table(arr: &Vec, table_view: Option<&Vec = Vec::new(); for (i, col_parts) in header_lines.iter().enumerate() { - let s = if line_idx < col_parts.len() { &col_parts[line_idx] } else { "" }; - parts.push(format!(" {:, table_view: Option<&Vec ColumnSpec { }; ColumnSpec { path, modifier } } else { - ColumnSpec { path: spec.to_string(), modifier: None } + ColumnSpec { + path: spec.to_string(), + modifier: None, + } } } @@ -1032,7 +1218,11 @@ fn get_value_by_path<'a>(v: &'a serde_json::Value, path: &str) -> &'a serde_json for seg in path.split('.') { match current { serde_json::Value::Object(map) => { - if let Some(next) = map.get(seg) { current = next; } else { return &serde_json::Value::Null; } + if let Some(next) = map.get(seg) { + current = next; + } else { + return &serde_json::Value::Null; + } } _ => return &serde_json::Value::Null, } @@ -1045,16 +1235,24 @@ fn humanize_column_label(path: &str) -> String { let spaced = last.replace(['_', '-'], " "); let mut out_words: Vec = Vec::new(); for w in spaced.split_whitespace() { - if w.is_empty() { continue; } + if w.is_empty() { + continue; + } let mut chars = w.chars(); if let Some(first) = chars.next() { let mut s = String::new(); s.push(first.to_ascii_uppercase()); - for c in chars { s.push(c.to_ascii_lowercase()); } + for c in chars { + s.push(c.to_ascii_lowercase()); + } out_words.push(s); } } - if out_words.is_empty() { last.to_string() } else { out_words.join(" ") } + if out_words.is_empty() { + last.to_string() + } else { + out_words.join(" ") + } } fn humanize_column_label_with_modifier(path: &str, modifier: &Option) -> String { @@ -1077,7 +1275,10 @@ fn scalar_to_string(v: &serde_json::Value) -> String { } } -fn scalar_to_string_with_modifier(v: &serde_json::Value, modifier: &Option) -> String { +fn scalar_to_string_with_modifier( + v: &serde_json::Value, + modifier: &Option, +) -> String { match modifier { Some(size_mod) => { // Try to parse as number for size conversion @@ -1248,7 +1449,12 @@ mod tests { }; let vars = HashMap::new(); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.method, "GET"); @@ -1279,7 +1485,12 @@ mod tests { let mut vars = HashMap::new(); vars.insert("id".to_string(), "123".to_string()); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.endpoint, "/users/123"); @@ -1312,7 +1523,10 @@ mod tests { let spec = build_request_from_command(None, &cmd, &vars, &selected); if let RequestSpec::Simple(raw) = spec { - assert_eq!(raw.body, Some(r#"{"name": "John", "email": "john@example.com"}"#.to_string())); + assert_eq!( + raw.body, + Some(r#"{"name": "John", "email": "john@example.com"}"#.to_string()) + ); } else { panic!("Expected RequestSpec::Simple"); } @@ -1372,7 +1586,11 @@ mod tests { let selected = HashSet::new(); let spec = build_request_from_command(None, &cmd, &vars, &selected); - if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + if let RequestSpec::CustomHandler { + handler_name, + vars: handler_vars, + } = spec + { assert_eq!(handler_name, "export_users"); assert_eq!(handler_vars.get("format"), Some(&"csv".to_string())); assert!(handler_vars.contains_key("uuid")); // Built-in variable added @@ -1385,17 +1603,15 @@ mod tests { fn test_build_request_scenario() { let scenario = mapping::Scenario { scenario_type: "sequential".to_string(), - steps: vec![ - mapping::ScenarioStep { - name: "step1".to_string(), - method: "POST".to_string(), - endpoint: "/start".to_string(), - body: None, - headers: HashMap::new(), - extract_response: HashMap::new(), - polling: None, - }, - ], + steps: vec![mapping::ScenarioStep { + name: "step1".to_string(), + method: "POST".to_string(), + endpoint: "/start".to_string(), + body: None, + headers: HashMap::new(), + extract_response: HashMap::new(), + polling: None, + }], }; let cmd = mapping::CommandSpec { @@ -1415,10 +1631,18 @@ mod tests { }; let vars = HashMap::new(); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Scenario(scenario_spec) = spec { - assert_eq!(scenario_spec.base_url, Some("https://api.example.com".to_string())); + assert_eq!( + scenario_spec.base_url, + Some("https://api.example.com".to_string()) + ); assert_eq!(scenario_spec.scenario.steps.len(), 1); assert!(scenario_spec.vars.contains_key("uuid")); // Built-in variable added } else { @@ -1627,7 +1851,7 @@ paths: {} #[test] fn test_scalar_to_string_number() { assert_eq!(scalar_to_string(&serde_json::json!(42)), "42"); - assert_eq!(scalar_to_string(&serde_json::json!(3.14)), "3.14"); + assert_eq!(scalar_to_string(&serde_json::json!(3.15)), "3.15"); } #[test] @@ -1786,7 +2010,8 @@ paths: {} #[test] fn test_humanize_column_label_with_modifier_gb() { - let result = humanize_column_label_with_modifier("disk_size", &Some(SizeModifier::Gigabytes)); + let result = + humanize_column_label_with_modifier("disk_size", &Some(SizeModifier::Gigabytes)); assert!(result.contains("Disk Size")); assert!(result.contains("GB")); } @@ -1800,7 +2025,8 @@ paths: {} #[test] fn test_humanize_column_label_with_modifier_kb() { - let result = humanize_column_label_with_modifier("cache_size", &Some(SizeModifier::Kilobytes)); + let result = + humanize_column_label_with_modifier("cache_size", &Some(SizeModifier::Kilobytes)); assert!(result.contains("Cache Size")); assert!(result.contains("KB")); } @@ -1815,13 +2041,11 @@ paths: {} #[test] fn test_apply_file_overrides_no_file_args() { - let args = vec![ - mapping::ArgSpec { - name: Some("name".to_string()), - arg_type: None, - ..Default::default() - }, - ]; + let args = vec![mapping::ArgSpec { + name: Some("name".to_string()), + arg_type: None, + ..Default::default() + }]; let mut vars = HashMap::new(); vars.insert("name".to_string(), "John".to_string()); apply_file_overrides(&args, &mut vars); @@ -1830,14 +2054,12 @@ paths: {} #[test] fn test_apply_file_overrides_file_arg_empty_path() { - let args = vec![ - mapping::ArgSpec { - name: Some("config_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("config".to_string()), - ..Default::default() - }, - ]; + let args = vec![mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }]; let mut vars = HashMap::new(); vars.insert("config_file".to_string(), "".to_string()); apply_file_overrides(&args, &mut vars); @@ -1846,16 +2068,17 @@ paths: {} #[test] fn test_apply_file_overrides_missing_file() { - let args = vec![ - mapping::ArgSpec { - name: Some("config_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("config".to_string()), - ..Default::default() - }, - ]; + let args = vec![mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }]; let mut vars = HashMap::new(); - vars.insert("config_file".to_string(), "/nonexistent/path/file.txt".to_string()); + vars.insert( + "config_file".to_string(), + "/nonexistent/path/file.txt".to_string(), + ); apply_file_overrides(&args, &mut vars); // Should not insert config since file doesn't exist assert!(!vars.contains_key("config")); @@ -1922,20 +2145,23 @@ paths: {} scenario: None, multipart: false, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("override_arg".to_string()), - endpoint: Some("/overridden".to_string()), - method: Some("POST".to_string()), - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("override_arg".to_string()), + endpoint: Some("/overridden".to_string()), + method: Some("POST".to_string()), + ..Default::default() + }], use_common_args: vec![], }; let vars = HashMap::new(); let mut selected = HashSet::new(); selected.insert("override_arg".to_string()); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.endpoint, "/overridden"); @@ -1959,13 +2185,11 @@ paths: {} scenario: None, multipart: true, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("file".to_string()), - file_upload: true, - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("file".to_string()), + file_upload: true, + ..Default::default() + }], use_common_args: vec![], }; let mut vars = HashMap::new(); @@ -2036,7 +2260,8 @@ paths: {} vars.insert("id".to_string(), "123".to_string()); vars.insert("name".to_string(), "Test".to_string()); - let result = build_raw_spec_from_step(&Some("https://api.example.com".to_string()), &step, &vars); + let result = + build_raw_spec_from_step(&Some("https://api.example.com".to_string()), &step, &vars); assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.method, "POST"); @@ -2271,20 +2496,23 @@ paths: {} scenario: None, multipart: false, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("custom_arg".to_string()), - headers: Some(arg_headers), - body: Some(r#"{"override": true}"#.to_string()), - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("custom_arg".to_string()), + headers: Some(arg_headers), + body: Some(r#"{"override": true}"#.to_string()), + ..Default::default() + }], use_common_args: vec![], }; let vars = HashMap::new(); let mut selected = HashSet::new(); selected.insert("custom_arg".to_string()); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert!(raw.headers.iter().any(|h| h.contains("X-Custom"))); @@ -2299,7 +2527,7 @@ paths: {} #[test] fn test_apply_file_overrides_with_real_file() { use std::io::Write; - + // Create a temp file let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("rclib_test_file.txt"); @@ -2307,19 +2535,20 @@ paths: {} writeln!(file, "file content here").unwrap(); drop(file); - let args = vec![ - mapping::ArgSpec { - name: Some("config_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("config".to_string()), - ..Default::default() - }, - ]; + let args = vec![mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }]; let mut vars = HashMap::new(); - vars.insert("config_file".to_string(), temp_file.to_string_lossy().to_string()); - + vars.insert( + "config_file".to_string(), + temp_file.to_string_lossy().to_string(), + ); + apply_file_overrides(&args, &mut vars); - + assert!(vars.contains_key("config")); assert!(vars.get("config").unwrap().contains("file content here")); @@ -2339,8 +2568,8 @@ paths: {} #[test] fn test_output_format_clone() { let format = OutputFormat::Human; - let cloned = format.clone(); - assert_eq!(cloned, OutputFormat::Human); + let copied = format; + assert_eq!(copied, OutputFormat::Human); } #[test] @@ -2399,7 +2628,11 @@ paths: {} endpoint: Some("/users".to_string()), body: None, headers: HashMap::new(), - table_view: Some(vec!["id".to_string(), "name".to_string(), "email".to_string()]), + table_view: Some(vec![ + "id".to_string(), + "name".to_string(), + "email".to_string(), + ]), scenario: None, multipart: false, custom_handler: None, @@ -2435,13 +2668,11 @@ paths: {} scenario: None, multipart: true, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("description".to_string()), - file_upload: false, // Not a file upload - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("description".to_string()), + file_upload: false, // Not a file upload + ..Default::default() + }], use_common_args: vec![], }; let mut vars = HashMap::new(); @@ -2507,7 +2738,12 @@ paths: {} let mut vars = HashMap::new(); vars.insert("job_name".to_string(), "test_job".to_string()); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Scenario(scenario_spec) = spec { assert_eq!(scenario_spec.scenario.scenario_type, "job_with_polling"); @@ -2524,7 +2760,7 @@ paths: {} #[test] fn test_build_request_custom_handler_with_file_override() { use std::io::Write; - + // Create a temp file let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("rclib_handler_test.txt"); @@ -2544,25 +2780,33 @@ paths: {} scenario: None, multipart: false, custom_handler: Some("process_handler".to_string()), - args: vec![ - mapping::ArgSpec { - name: Some("input_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("input_content".to_string()), - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("input_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("input_content".to_string()), + ..Default::default() + }], use_common_args: vec![], }; let mut vars = HashMap::new(); - vars.insert("input_file".to_string(), temp_file.to_string_lossy().to_string()); + vars.insert( + "input_file".to_string(), + temp_file.to_string_lossy().to_string(), + ); let selected = HashSet::new(); let spec = build_request_from_command(None, &cmd, &vars, &selected); - if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + if let RequestSpec::CustomHandler { + handler_name, + vars: handler_vars, + } = spec + { assert_eq!(handler_name, "process_handler"); assert!(handler_vars.contains_key("input_content")); - assert!(handler_vars.get("input_content").unwrap().contains("handler file content")); + assert!(handler_vars + .get("input_content") + .unwrap() + .contains("handler file content")); } else { panic!("Expected RequestSpec::CustomHandler"); } @@ -2591,7 +2835,7 @@ paths: {} is_success: false, }; let cloned = result.clone(); - assert_eq!(cloned.is_success, false); + assert!(!cloned.is_success); assert_eq!(cloned.duration.as_secs(), 1); } @@ -2626,7 +2870,7 @@ paths: {} let gb = SizeModifier::Gigabytes; let mb = SizeModifier::Megabytes; let kb = SizeModifier::Kilobytes; - + assert!(format!("{:?}", gb).contains("Gigabytes")); assert!(format!("{:?}", mb).contains("Megabytes")); assert!(format!("{:?}", kb).contains("Kilobytes")); @@ -2644,7 +2888,7 @@ paths: {} #[test] fn test_build_request_simple_with_file_override() { use std::io::Write; - + // Create a temp file let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("rclib_simple_test.json"); @@ -2664,20 +2908,26 @@ paths: {} scenario: None, multipart: false, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("body_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("body".to_string()), - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("body_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("body".to_string()), + ..Default::default() + }], use_common_args: vec![], }; let mut vars = HashMap::new(); - vars.insert("body_file".to_string(), temp_file.to_string_lossy().to_string()); + vars.insert( + "body_file".to_string(), + temp_file.to_string_lossy().to_string(), + ); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert!(raw.body.is_some()); @@ -2695,7 +2945,7 @@ paths: {} #[test] fn test_build_request_scenario_with_file_override() { use std::io::Write; - + // Create a temp file let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("rclib_scenario_test.json"); @@ -2743,24 +2993,34 @@ paths: {} scenario: Some(scenario), multipart: false, custom_handler: None, - args: vec![ - mapping::ArgSpec { - name: Some("config_file".to_string()), - arg_type: Some("file".to_string()), - file_overrides_value_of: Some("config".to_string()), - ..Default::default() - }, - ], + args: vec![mapping::ArgSpec { + name: Some("config_file".to_string()), + arg_type: Some("file".to_string()), + file_overrides_value_of: Some("config".to_string()), + ..Default::default() + }], use_common_args: vec![], }; let mut vars = HashMap::new(); - vars.insert("config_file".to_string(), temp_file.to_string_lossy().to_string()); + vars.insert( + "config_file".to_string(), + temp_file.to_string_lossy().to_string(), + ); let selected = HashSet::new(); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), &cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + &cmd, + &vars, + &selected, + ); if let RequestSpec::Scenario(scenario_spec) = spec { assert!(scenario_spec.vars.contains_key("config")); - assert!(scenario_spec.vars.get("config").unwrap().contains("scenario config content")); + assert!(scenario_spec + .vars + .get("config") + .unwrap() + .contains("scenario config content")); } else { panic!("Expected RequestSpec::Scenario"); } @@ -2830,10 +3090,13 @@ paths: {} #[test] fn test_print_array_table_with_all_modifiers() { - let arr = vec![ - serde_json::json!({"gb": 1073741824_i64, "mb": 1048576_i64, "kb": 1024_i64}), + let arr = + vec![serde_json::json!({"gb": 1073741824_i64, "mb": 1048576_i64, "kb": 1024_i64})]; + let cols = vec![ + "gb:gb".to_string(), + "mb:mb".to_string(), + "kb:kb".to_string(), ]; - let cols = vec!["gb:gb".to_string(), "mb:mb".to_string(), "kb:kb".to_string()]; print_array_table(&arr, Some(&cols)); } diff --git a/rclib/src/mapping.rs b/rclib/src/mapping.rs index 5f4d01b..4d125bb 100644 --- a/rclib/src/mapping.rs +++ b/rclib/src/mapping.rs @@ -1,6 +1,6 @@ -use std::collections::HashMap; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FlatSpec { @@ -209,8 +209,8 @@ impl<'de> serde::Deserialize<'de> for CommandNode { }); if has_subcommands { - let group: CommandGroup = serde_yaml::from_value(value) - .map_err(serde::de::Error::custom)?; + let group: CommandGroup = + serde_yaml::from_value(value).map_err(serde::de::Error::custom)?; return Ok(CommandNode::Group(group)); } @@ -224,15 +224,15 @@ impl<'de> serde::Deserialize<'de> for CommandNode { }); if has_command_fields { - let command: CommandSpec = serde_yaml::from_value(value) - .map_err(serde::de::Error::custom)?; + let command: CommandSpec = + serde_yaml::from_value(value).map_err(serde::de::Error::custom)?; return Ok(CommandNode::Command(command)); } } // Default to Command for backward compatibility - let command: CommandSpec = serde_yaml::from_value(value) - .map_err(serde::de::Error::custom)?; + let command: CommandSpec = + serde_yaml::from_value(value).map_err(serde::de::Error::custom)?; Ok(CommandNode::Command(command)) } } @@ -253,7 +253,8 @@ pub fn parse_mapping_root(yaml: &str) -> Result { // Peek to see if this is hierarchical (has top-level 'commands') let val: serde_yaml::Value = serde_yaml::from_str(yaml).context("Invalid YAML")?; if val.get("commands").is_some() { - let spec: HierSpec = serde_yaml::from_value(val).context("Failed to parse hierarchical mapping")?; + let spec: HierSpec = + serde_yaml::from_value(val).context("Failed to parse hierarchical mapping")?; Ok(MappingRoot::Hier(spec)) } else { let spec: FlatSpec = serde_yaml::from_str(yaml).context("Failed to parse flat mapping")?; @@ -269,25 +270,28 @@ pub fn derive_args_from_pattern(pattern: &str) -> Vec { let mut args: Vec = Vec::new(); for tok in pattern.split_whitespace() { if is_placeholder(tok) { - let name = tok.trim_start_matches('{').trim_end_matches('}').to_string(); - args.push(ArgSpec { - name: Some(name.clone()), - help: None, - positional: Some(true), - long: Some(name), - short: None, - required: Some(true), - default: None, - arg_type: None, - value: None, - file_upload: false, - file_overrides_value_of: None, - inherit: None, - endpoint: None, - method: None, - headers: None, - body: None, - }); + let name = tok + .trim_start_matches('{') + .trim_end_matches('}') + .to_string(); + args.push(ArgSpec { + name: Some(name.clone()), + help: None, + positional: Some(true), + long: Some(name), + short: None, + required: Some(true), + default: None, + arg_type: None, + value: None, + file_upload: false, + file_overrides_value_of: None, + inherit: None, + endpoint: None, + method: None, + headers: None, + body: None, + }); } } args @@ -428,7 +432,10 @@ commands: default: json "#; let spec = parse_flat_spec(yaml).unwrap(); - assert_eq!(spec.commands[0].custom_handler, Some("export_users".to_string())); + assert_eq!( + spec.commands[0].custom_handler, + Some("export_users".to_string()) + ); } #[test] @@ -647,7 +654,9 @@ commands: assert_eq!(scenario.scenario_type, "sequential"); assert_eq!(scenario.steps.len(), 2); assert_eq!(scenario.steps[0].name, "create"); - assert!(scenario.steps[0].extract_response.contains_key("deployment_id")); + assert!(scenario.steps[0] + .extract_response + .contains_key("deployment_id")); assert!(scenario.steps[1].polling.is_some()); } @@ -717,6 +726,9 @@ commands: let spec = parse_flat_spec(yaml).unwrap(); let arg = &spec.commands[0].args[0]; assert_eq!(arg.arg_type, Some("file".to_string())); - assert_eq!(arg.file_overrides_value_of, Some("config_content".to_string())); + assert_eq!( + arg.file_overrides_value_of, + Some("config_content".to_string()) + ); } } diff --git a/rclib/tests/integration_tests.rs b/rclib/tests/integration_tests.rs index 1e248d6..1dec399 100644 --- a/rclib/tests/integration_tests.rs +++ b/rclib/tests/integration_tests.rs @@ -4,11 +4,12 @@ use rclib::{ build_request_from_command, + cli::{ + build_cli, collect_subcommand_path, collect_vars_from_matches, validate_handlers, + HandlerRegistry, + }, mapping::{parse_mapping_root, MappingRoot}, - cli::{build_cli, collect_subcommand_path, collect_vars_from_matches, HandlerRegistry, validate_handlers}, - RequestSpec, - OutputFormat, - ExecutionConfig, + ExecutionConfig, OutputFormat, RequestSpec, }; // ==================== Mapping → CLI Integration ==================== @@ -51,7 +52,9 @@ commands: assert!(path_map.contains_key(&vec!["users".to_string(), "get".to_string()])); // 4. Parse arguments - let matches = app.try_get_matches_from(["cli", "users", "list", "--limit", "25"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "list", "--limit", "25"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); assert_eq!(path, vec!["users", "list"]); @@ -63,7 +66,12 @@ commands: assert!(selected.contains("limit")); // 6. Build request spec - let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.method, "GET"); assert_eq!(raw.endpoint, "/users"); @@ -89,12 +97,19 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "users", "get", "123"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "get", "123"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.endpoint, "/users/123"); } else { @@ -134,14 +149,20 @@ commands: // Build and parse CLI let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "export", "users", "--format", "csv"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "export", "users", "--format", "csv"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); // Build request spec let spec = build_request_from_command(None, cmd, &vars, &selected); - if let RequestSpec::CustomHandler { handler_name, vars: handler_vars } = spec { + if let RequestSpec::CustomHandler { + handler_name, + vars: handler_vars, + } = spec + { assert_eq!(handler_name, "export_users"); assert_eq!(handler_vars.get("format"), Some(&"csv".to_string())); assert_eq!(handler_vars.get("output"), Some(&"users.json".to_string())); @@ -185,21 +206,38 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from([ - "cli", "api", "call", "acme", "myproject", - "--name", "Test Project", - "--desc", "A test project", - "--token", "secret123" - ]).unwrap(); + let matches = app + .try_get_matches_from([ + "cli", + "api", + "call", + "acme", + "myproject", + "--name", + "Test Project", + "--desc", + "A test project", + "--token", + "secret123", + ]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + cmd, + &vars, + &selected, + ); if let RequestSpec::Simple(raw) = spec { assert_eq!(raw.endpoint, "/orgs/acme/projects/myproject"); - assert_eq!(raw.body, Some(r#"{"name": "Test Project", "description": "A test project"}"#.to_string())); + assert_eq!( + raw.body, + Some(r#"{"name": "Test Project", "description": "A test project"}"#.to_string()) + ); assert!(raw.headers.iter().any(|h| h.contains("Bearer secret123"))); assert!(raw.headers.iter().any(|h| h.contains("X-Org-Id: acme"))); } else { @@ -245,9 +283,9 @@ commands: ])); // Parse and execute - let matches = app.try_get_matches_from([ - "cli", "cloud", "compute", "instances", "get", "vm-123" - ]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "cloud", "compute", "instances", "get", "vm-123"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); assert_eq!(path, vec!["cloud", "compute", "instances", "get"]); @@ -281,14 +319,19 @@ commands: let (app, path_map) = build_cli(&root, "https://api.example.com"); // Without flag - let matches = app.clone().try_get_matches_from(["cli", "users", "list"]).unwrap(); + let matches = app + .clone() + .try_get_matches_from(["cli", "users", "list"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, _, _) = collect_vars_from_matches(cmd, leaf); assert_eq!(vars.get("verbose"), Some(&"false".to_string())); // With flag - let matches = app.try_get_matches_from(["cli", "users", "list", "--verbose"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "list", "--verbose"]) + .unwrap(); let (_path, leaf) = collect_subcommand_path(&matches); let (vars, _, _) = collect_vars_from_matches(cmd, leaf); assert_eq!(vars.get("verbose"), Some(&"true".to_string())); @@ -319,7 +362,9 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "users", "list", "--output", "yaml"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "users", "list", "--output", "yaml"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, _, _) = collect_vars_from_matches(cmd, leaf); @@ -364,7 +409,10 @@ commands: let result = validate_handlers(&root, ®); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("nonexistent_handler")); + assert!(result + .unwrap_err() + .to_string() + .contains("nonexistent_handler")); } #[test] @@ -404,12 +452,19 @@ commands: let root = parse_mapping_root(yaml).unwrap(); let (app, path_map) = build_cli(&root, "https://api.example.com"); - let matches = app.try_get_matches_from(["cli", "deploy", "app", "--app", "myapp"]).unwrap(); + let matches = app + .try_get_matches_from(["cli", "deploy", "app", "--app", "myapp"]) + .unwrap(); let (path, leaf) = collect_subcommand_path(&matches); let cmd = path_map.get(&path).unwrap(); let (vars, selected, _) = collect_vars_from_matches(cmd, leaf); - let spec = build_request_from_command(Some("https://api.example.com".to_string()), cmd, &vars, &selected); + let spec = build_request_from_command( + Some("https://api.example.com".to_string()), + cmd, + &vars, + &selected, + ); if let RequestSpec::Scenario(scenario_spec) = spec { assert_eq!(scenario_spec.scenario.steps.len(), 2); assert_eq!(scenario_spec.scenario.steps[0].name, "create_deployment"); From fb55bacc4022fef4d39f2f2406e7636cd291f28b Mon Sep 17 00:00:00 2001 From: Alexis Delain Date: Tue, 16 Dec 2025 10:01:32 +0200 Subject: [PATCH 4/5] chore: Makefile Signed-off-by: Alexis Delain --- Makefile | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ deny.toml | 133 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 Makefile create mode 100644 deny.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7988e22 --- /dev/null +++ b/Makefile @@ -0,0 +1,167 @@ +# rclib Makefile +# Thorough code checking and development automation + +CI := 1 + +# Show the help message with list of commands (default target) +help: + @echo "rclib Development Commands" + @echo "==========================" + @echo "" + @echo "Code Formatting:" + @echo " make fmt - Check code formatting" + @echo " make dev-fmt - Auto-fix code formatting" + @echo "" + @echo "Code Quality:" + @echo " make clippy - Run clippy linter" + @echo " make lint - Check for compile warnings" + @echo " make dev-clippy - Auto-fix clippy warnings" + @echo "" + @echo "Code Safety:" + @echo " make kani - Run Kani verifier for safety checks" + @echo " make geiger - Run Geiger scanner for unsafe code" + @echo " make safety - Run all code safety checks" + @echo "" + @echo "Security:" + @echo " make deny - Check licenses and dependencies" + @echo " make security - Run all security checks" + @echo "" + @echo "Tests:" + @echo " make test - Run all tests" + @echo " make test-lib - Run library tests only" + @echo " make test-int - Run integration tests only" + @echo "" + @echo "Coverage:" + @echo " make coverage - Generate code coverage report (HTML)" + @echo " make coverage-text - Generate code coverage report (text)" + @echo "" + @echo "Development:" + @echo " make dev - Auto-fix formatting and clippy, then test" + @echo " make dev-test - Run tests in development mode" + @echo "" + @echo "Build:" + @echo " make build - Make a release build" + @echo " make run - Run the dummyjson-cli example" + @echo "" + @echo "Main Targets:" + @echo " make check - Run all quality checks" + @echo " make ci - Run CI pipeline" + @echo " make all - Run all checks, tests, and build" + +# -------- Code formatting -------- +.PHONY: fmt + +# Check code formatting +fmt: + cargo fmt --all -- --check + +# -------- Code quality -------- +.PHONY: clippy lint + +# Run clippy linter +clippy: + cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::perf + +# Check there are no compile time warnings +lint: + RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --all-features + +# -------- Code safety checks -------- +.PHONY: kani geiger safety + +# The Kani Rust Verifier for checking safety of the code +kani: + @command -v kani >/dev/null || \ + (echo "Installing Kani verifier..." && \ + cargo install --locked kani-verifier) + cargo kani --workspace --all-features + +# Run Geiger scanner for unsafe code in dependencies +geiger: + cargo geiger --all-features + +# Run all code safety checks +safety: clippy lint + @echo "OK. Rust Safety Pipeline complete" + +# -------- Code security checks -------- +.PHONY: deny security + +# Check licenses and dependencies +deny: + cargo deny check + +# Run all security checks +security: deny + @echo "OK. Rust Security Pipeline complete" + +# -------- Development and auto fix -------- +.PHONY: dev dev-fmt dev-clippy dev-test + +# Run tests in development mode +dev-test: + cargo test --workspace + +# Auto-fix code formatting +dev-fmt: + cargo fmt --all + +# Auto-fix clippy warnings +dev-clippy: + cargo clippy --workspace --all-targets --fix --allow-dirty + +# Auto-fix formatting and clippy warnings +dev: dev-fmt dev-clippy dev-test + +# -------- Tests -------- +.PHONY: test test-lib test-int + +# Run all tests +test: + cargo test --workspace + +# Run library tests only +test-lib: + cargo test -p rclib --lib + +# Run integration tests only +test-int: + cargo test -p rclib --test integration_tests + +# -------- Code coverage -------- +.PHONY: coverage coverage-text + +# Generate code coverage report (HTML) +coverage: + @command -v cargo-llvm-cov >/dev/null || (echo "Installing cargo-llvm-cov..." && cargo install cargo-llvm-cov) + cargo llvm-cov --workspace --html + @echo "Coverage report generated at target/llvm-cov/html/index.html" + +# Generate code coverage report (text) +coverage-text: + @command -v cargo-llvm-cov >/dev/null || (echo "Installing cargo-llvm-cov..." && cargo install cargo-llvm-cov) + cargo llvm-cov --workspace + +# -------- Build -------- +.PHONY: build run + +# Make a release build using stable toolchain +build: + cargo +stable build --release + +# Run the dummyjson-cli example +run: + cargo run -p dummyjson-cli -- --help + +# -------- Main targets -------- +.PHONY: check ci all + +# Run all quality checks +check: fmt clippy lint test security + +# Run CI pipeline +ci: check + +# Run all necessary quality checks and tests and then build the release binary +all: check build + @echo "All checks passed and release binary built successfully" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..b22d988 --- /dev/null +++ b/deny.toml @@ -0,0 +1,133 @@ +# cargo-deny configuration for security, licenses, bans, and sources. +# Docs: https://embarkstudios.github.io/cargo-deny/ + +################################################################################ +# Dependency graph construction +################################################################################ +[graph] +# Optionally restrict targets to check (kept commented by default). +# targets = [ +# "x86_64-unknown-linux-gnu", +# { triple = "wasm32-unknown-unknown", features = ["atomics"] }, +# ] +# all-features = true +# exclude-dev = true +# exclude-unpublished = false + +################################################################################ +# Security advisories (RustSec) +################################################################################ +[advisories] +# Local clone/cache of the RustSec advisory DB +db-path = ".cargo/advisory-db" +# Upstream advisory DBs to use +db-urls = ["https://github.com/RustSec/advisory-db"] + +# How to treat "unmaintained" advisories: +# one of: "all" | "workspace" | "transitive" | "none" +# (This is NOT a severity level; it scopes where the rule applies.) +unmaintained = "workspace" + +# Yanked crate versions still use classic lint levels. +# one of: "deny" | "warn" | "allow" +yanked = "warn" + +# List of advisory IDs to ignore (RUSTSEC-YYYY-XXXX, etc.) +ignore = [ + # "RUSTSEC-0000-0000", +] + +# Note: +# - Modern cargo-deny versions treat vulnerabilities as hard errors by default. +# - If you must silence a specific vuln, add its ID to `ignore` above. + +################################################################################ +# License policy +################################################################################ +[licenses] +# Allow-list of SPDX license IDs +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "Unicode-3.0", + "CDLA-Permissive-2.0", + "MPL-2.0", + "Zlib" +] + +# Confidence threshold (0.0..1.0) for license text detection +confidence-threshold = 0.8 + +# Per-crate exceptions (license(s) allowed only for a specific crate/version) +exceptions = [ + # { name = "some-crate", version = "*", allow = ["Zlib"] }, +] + +# Private workspace crates handling +[licenses.private] +# If true, ignore unpublished/private workspace crates for license checks +ignore = false +# Private registries considered "published" for the rule above +registries = [ + # "https://example.com/registry", +] + +################################################################################ +# Duplicate versions / wildcards / bans +################################################################################ +[bans] +# Multiple versions of the same crate in the graph +multiple-versions = "warn" + +# Version wildcards like "*" +wildcards = "allow" + +# How to highlight in dot graphs when multiple versions are present +# "lowest-version" | "simplest-path" | "all" +highlight = "all" + +# Default lint level for `default-features` on workspace deps (requires the +# `workspace-dependencies` feature in cargo-deny to take effect) +workspace-default-features = "allow" + +# Allow-list of specific crates (use carefully) +allow = [ + # { name = "ansi_term", version = "=0.11.0" }, +] + +# Deny-list of specific crates (optionally with version ranges/wrappers) +deny = [ + # { name = "some-bad-crate", version = "*", wrappers = [] }, +] + +# Skip specific crate versions when checking for duplicates +skip = [ + # { name = "ansi_term", version = "=0.11.0" }, +] + +# Remove a crate subtree entirely from the graph for checks (stronger than skip) +skip-tree = [ + # { name = "ansi_term", version = "=0.11.0", depth = 1 }, +] + +################################################################################ +# Allowed sources (registries and git) +################################################################################ +[sources] +# Non-allowed registry encountered +unknown-registry = "warn" + +# Non-allowed git source encountered +unknown-git = "warn" + +# Allowed crate registries (empty list => none allowed) +# Default crates.io index: +allow-registry = ["https://github.com/rust-lang/crates.io-index"] + +# Allowed git repositories (empty => none) +allow-git = [] From 197f0570c66021b78c4f4cd623bff5978bbbd1c8 Mon Sep 17 00:00:00 2001 From: Alexis Delain Date: Thu, 18 Dec 2025 17:53:22 +0200 Subject: [PATCH 5/5] style: add justification for `leak_str` Signed-off-by: Alexis Delain --- rclib/src/cli.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rclib/src/cli.rs b/rclib/src/cli.rs index ee879c2..242a663 100644 --- a/rclib/src/cli.rs +++ b/rclib/src/cli.rs @@ -15,6 +15,22 @@ struct TreeNode { about: Option, } +/// FIXME: Memory leak for 'static lifetime requirement +/// +/// Clap's builder API requires `'static` strings for `Command::new()`, `Arg::new()`, +/// `.long()`, `.about()`, and `.default_value()`. This is a hard requirement in clap's +/// type signatures - the compiler error is: "argument requires that 'a must outlive 'static". +/// +/// Since our CLI is built dynamically from runtime YAML parsing, the command/arg names +/// and descriptions are owned `String`s that only live during `build_cli()` execution. +/// We must leak them to satisfy clap's `'static` requirement. +/// +/// Why this is acceptable: +/// 1. CLI building happens once at program startup +/// 2. The leaked memory is small (~10KB for typical command structures) +/// 3. CLI programs are short-lived and exit shortly after argument parsing +/// 4. Alternative approaches (leaking entire `MappingRoot`, using thread-local storage) +/// would leak similar amounts of memory with added complexity fn leak_str>(s: S) -> &'static str { Box::leak(s.into().into_boxed_str()) }