#![allow(dead_code)]
use clap::Parser;
use reqwest::Client;
use serde::Deserialize;
use similar::{ChangeTag, TextDiff};
use std::collections::HashMap;
use std::time::Duration;
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const CYAN: &str = "\x1b[36m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
#[derive(Parser, Debug)]
#[command(name = "rift-verify")]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, default_value = "http://localhost:2525")]
admin_url: String,
#[arg(short, long)]
port: Option<u16>,
#[arg(short = 'c', long)]
show_curl: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short, long, default_value = "10")]
timeout: u64,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_dynamic: bool,
#[arg(long)]
status_only: bool,
#[arg(long)]
demo: bool,
}
#[derive(Debug, Deserialize)]
struct RootResponse {
#[serde(default)]
imposters: Option<Vec<ImposterLink>>,
}
#[derive(Debug, Deserialize)]
struct ImposterLink {
port: u16,
protocol: String,
#[serde(rename = "_links")]
links: Option<HashMap<String, LinkInfo>>,
}
#[derive(Debug, Deserialize)]
struct LinkInfo {
href: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImposterDetails {
port: u16,
protocol: String,
name: Option<String>,
#[serde(default)]
stubs: Vec<Stub>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Stub {
#[serde(default)]
id: Option<String>,
#[serde(default)]
predicates: Vec<serde_json::Value>,
#[serde(default)]
responses: Vec<serde_json::Value>,
}
#[derive(Debug, Clone)]
struct TestCase {
stub_index: usize,
stub_id: Option<String>,
method: String,
path: String,
headers: HashMap<String, String>,
query_params: HashMap<String, String>,
body: Option<String>,
expected_status: u16,
expected_headers: HashMap<String, String>,
expected_body: Option<serde_json::Value>,
is_dynamic: bool,
skip_reason: Option<String>,
is_no_match_stub: bool,
}
#[derive(Debug)]
struct TestResult {
test_case: TestCase,
success: bool,
actual_status: Option<u16>,
actual_headers: Option<HashMap<String, String>>,
actual_body: Option<String>,
error: Option<String>,
duration_ms: u128,
failure_reasons: Vec<FailureReason>,
}
#[derive(Debug, Default)]
struct VerificationSummary {
total_imposters: usize,
total_stubs: usize,
total_tests: usize,
passed: usize,
failed: usize,
skipped: usize,
failures: Vec<FailureDetails>,
}
#[derive(Debug)]
enum FailureReason {
RequestError(String),
StatusMismatch { expected: u16, actual: u16 },
HeaderMissing { header_name: String },
HeaderMismatch {
header_name: String,
expected: String,
actual: String,
},
BodyMismatch { expected: String, actual: String },
BodyMissing { expected: String },
}
impl FailureReason {
fn hint(&self) -> String {
match self {
FailureReason::RequestError(err) => {
if err.contains("Connection refused") {
"Hint: The imposter may not be running. Check that Rift is started and the imposter is created.".to_string()
} else if err.contains("timed out") {
"Hint: Request timed out. The server may be slow or unresponsive. Try increasing --timeout.".to_string()
} else {
format!("Hint: HTTP request failed - {err}")
}
}
FailureReason::StatusMismatch { expected, actual } => {
match *actual {
404 => format!("Hint: Got 404 instead of {expected}. The stub predicate may not match the test request path/method."),
500 => format!("Hint: Got 500 instead of {expected}. Check server logs for errors."),
_ => format!("Hint: Expected status {expected} but got {actual}. Verify the stub response configuration."),
}
}
FailureReason::HeaderMissing { header_name } => {
format!("Hint: Expected header '{header_name}' is missing from the response. Add it to the stub's response headers.")
}
FailureReason::HeaderMismatch { header_name, expected, actual } => {
format!("Hint: Header '{header_name}' has wrong value.\n Expected: \"{expected}\"\n Actual: \"{actual}\"")
}
FailureReason::BodyMismatch { .. } => {
"Hint: Response body doesn't match. See diff below for details.".to_string()
}
FailureReason::BodyMissing { .. } => {
"Hint: Expected a response body but got an empty response.".to_string()
}
}
}
}
#[derive(Debug)]
struct FailureDetails {
imposter_port: u16,
imposter_name: Option<String>,
stub_index: usize,
stub_id: Option<String>,
test_description: String,
expected: String,
actual: String,
curl_command: Option<String>,
failure_reasons: Vec<FailureReason>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let client = Client::builder()
.timeout(Duration::from_secs(args.timeout))
.build()?;
if args.demo {
demo_enhanced_error_output();
return Ok(());
}
println!("{BOLD}{CYAN}Rift Stub Verifier{RESET}");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("Admin URL: {}", args.admin_url);
println!();
let imposters = fetch_imposters(&client, &args.admin_url, args.port).await?;
if imposters.is_empty() {
println!("{YELLOW}Warning:{RESET} No imposters found");
return Ok(());
}
let mut summary = VerificationSummary {
total_imposters: imposters.len(),
..Default::default()
};
for imposter in &imposters {
println!(
"{}Imposter:{} {} (port {})",
BOLD,
RESET,
imposter.name.as_deref().unwrap_or("unnamed"),
imposter.port
);
summary.total_stubs += imposter.stubs.len();
if imposter.stubs.is_empty() {
println!(" └─ No stubs defined");
println!();
continue;
}
for (stub_index, stub) in imposter.stubs.iter().enumerate() {
let test_cases = generate_test_cases(stub_index, stub, args.skip_dynamic);
summary.total_tests += test_cases.len();
for test_case in test_cases {
if args.show_curl || args.verbose {
let curl = generate_curl_command(imposter.port, &test_case);
println!(" {DIM}{curl}{RESET}");
}
if let Some(reason) = &test_case.skip_reason {
if test_case.is_no_match_stub {
summary.passed += 1;
if args.verbose {
println!(
" {}PASS{} Stub #{} - {} {} ({})",
GREEN, RESET, stub_index, test_case.method, test_case.path, reason
);
}
} else {
summary.skipped += 1;
if args.verbose {
println!(
" {}SKIP{} Stub #{} - {}",
YELLOW,
RESET,
stub_index,
test_case.skip_reason.as_ref().unwrap()
);
}
}
continue;
}
if args.dry_run {
println!(
" {}DRY-RUN{} Stub #{}{} - {} {}",
CYAN,
RESET,
stub_index,
test_case
.stub_id
.as_ref()
.map(|id| format!(" [{id}]"))
.unwrap_or_default(),
test_case.method,
test_case.path
);
summary.skipped += 1;
continue;
}
let result =
execute_test(&client, imposter.port, &test_case, args.status_only).await;
if result.success {
summary.passed += 1;
if args.verbose {
println!(
" {}PASS{} Stub #{}{} - {} {} -> {} ({}ms)",
GREEN,
RESET,
stub_index,
test_case
.stub_id
.as_ref()
.map(|id| format!(" [{id}]"))
.unwrap_or_default(),
test_case.method,
test_case.path,
result.actual_status.unwrap_or(0),
result.duration_ms
);
}
} else {
summary.failed += 1;
let failure = FailureDetails {
imposter_port: imposter.port,
imposter_name: imposter.name.clone(),
stub_index,
stub_id: test_case.stub_id.clone(),
test_description: format!("{} {}", test_case.method, test_case.path),
expected: format!(
"status={}, body={:?}",
test_case.expected_status, test_case.expected_body
),
actual: if let Some(err) = &result.error {
format!("error: {err}")
} else {
format!(
"status={}, body={:?}",
result.actual_status.unwrap_or(0),
result.actual_body
)
},
curl_command: Some(generate_curl_command(imposter.port, &test_case)),
failure_reasons: result.failure_reasons,
};
println!(
" {}FAIL{} Stub #{}{} - {} {}",
RED,
RESET,
stub_index,
test_case
.stub_id
.as_ref()
.map(|id| format!(" [{id}]"))
.unwrap_or_default(),
test_case.method,
test_case.path
);
if args.verbose && !failure.failure_reasons.is_empty() {
println!(" {BOLD}Why it failed:{RESET}");
for reason in &failure.failure_reasons {
print_failure_reason(reason);
}
}
summary.failures.push(failure);
}
}
}
println!();
}
print_summary(&summary, args.show_curl);
if summary.failed > 0 {
std::process::exit(1);
}
Ok(())
}
async fn fetch_imposters(
client: &Client,
admin_url: &str,
filter_port: Option<u16>,
) -> Result<Vec<ImposterDetails>, Box<dyn std::error::Error>> {
let imposters_url = format!("{admin_url}/imposters");
let response = client.get(&imposters_url).send().await?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch imposters: {} {}",
response.status(),
response.text().await.unwrap_or_default()
)
.into());
}
let imposters_response: serde_json::Value = response.json().await?;
let imposter_links: Vec<ImposterLink> =
if let Some(imposters) = imposters_response.get("imposters") {
serde_json::from_value(imposters.clone())?
} else {
vec![]
};
let mut imposters = Vec::new();
for link in imposter_links {
if let Some(port) = filter_port {
if link.port != port {
continue;
}
}
let detail_url = format!("{}/imposters/{}", admin_url, link.port);
let detail_response = client.get(&detail_url).send().await?;
if detail_response.status().is_success() {
let details: ImposterDetails = detail_response.json().await?;
imposters.push(details);
}
}
Ok(imposters)
}
fn generate_test_cases(stub_index: usize, stub: &Stub, skip_dynamic: bool) -> Vec<TestCase> {
let mut test_cases = Vec::new();
let (is_dynamic, dynamic_type) = check_if_dynamic(&stub.responses);
let is_no_match_stub = check_if_no_match_stub(&stub.predicates);
let (method, path, headers, query_params, body) = parse_predicates(&stub.predicates);
if is_no_match_stub {
test_cases.push(TestCase {
stub_index,
stub_id: stub.id.clone(),
method,
path,
headers,
query_params,
body,
expected_status: 200,
expected_headers: HashMap::new(),
expected_body: None,
is_dynamic: false,
skip_reason: Some("no-match stub (passes by design)".to_string()),
is_no_match_stub: true,
});
return test_cases;
}
if is_dynamic && skip_dynamic {
test_cases.push(TestCase {
stub_index,
stub_id: stub.id.clone(),
method,
path,
headers,
query_params,
body,
expected_status: 200,
expected_headers: HashMap::new(),
expected_body: None,
is_dynamic: true,
skip_reason: dynamic_type,
is_no_match_stub: false,
});
return test_cases;
}
let (expected_status, expected_headers, expected_body) =
extract_expected_response(&stub.responses);
test_cases.push(TestCase {
stub_index,
stub_id: stub.id.clone(),
method,
path,
headers,
query_params,
body,
expected_status,
expected_headers,
expected_body,
is_dynamic,
skip_reason: None,
is_no_match_stub: false,
});
test_cases
}
fn check_if_no_match_stub(predicates: &[serde_json::Value]) -> bool {
let no_match_patterns = [
"DONT MATCH",
"DO NOT MATCH",
"NEVER MATCH",
"NO MATCH",
"NOMATCH",
];
for predicate in predicates {
for key in ["equals", "contains", "startsWith", "endsWith", "deepEquals"] {
if let Some(pred) = predicate.get(key) {
if let Some(path) = pred.get("path").and_then(|v| v.as_str()) {
let path_upper = path.to_uppercase();
for pattern in &no_match_patterns {
if path_upper.contains(pattern) {
return true;
}
}
}
if let Some(body) = pred.get("body").and_then(|v| v.as_str()) {
let body_upper = body.to_uppercase();
for pattern in &no_match_patterns {
if body_upper.contains(pattern) {
return true;
}
}
}
}
}
}
false
}
fn check_if_dynamic(responses: &[serde_json::Value]) -> (bool, Option<String>) {
if responses.is_empty() {
return (false, None);
}
if responses.len() > 1 {
return (
true,
Some(format!("cycling responses ({} responses)", responses.len())),
);
}
let first = &responses[0];
if first.get("inject").is_some() {
return (true, Some("inject response (JavaScript)".to_string()));
}
if let Some(proxy) = first.get("proxy") {
if proxy.is_object() && proxy.get("to").is_some() {
return (true, Some("proxy response".to_string()));
}
}
if first.get("fault").is_some() {
return (true, Some("fault injection".to_string()));
}
if let Some(rift) = first.get("_rift") {
if rift.get("script").is_some() {
return (true, Some("Rift script response".to_string()));
}
}
if let Some(behaviors) = first.get("_behaviors") {
if behaviors.get("repeat").is_some() {
return (true, Some("repeat behavior (stateful)".to_string()));
}
}
(false, None)
}
fn extract_expected_response(
responses: &[serde_json::Value],
) -> (u16, HashMap<String, String>, Option<serde_json::Value>) {
if responses.is_empty() {
return (200, HashMap::new(), None);
}
let first = &responses[0];
let has_is_response = first.get("is").is_some();
if !has_is_response {
if let Some(proxy) = first.get("proxy") {
if proxy.is_object() && proxy.get("to").is_some() {
return (200, HashMap::new(), None);
}
}
}
if first.get("inject").is_some() {
return (200, HashMap::new(), None);
}
if let Some(fault) = first.get("fault") {
if let Some(status) = fault.get("status").and_then(|v| v.as_u64()) {
return (status as u16, HashMap::new(), None);
}
return (500, HashMap::new(), None);
}
if let Some(is_response) = first.get("is") {
let status = is_response
.get("statusCode")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(200) as u16;
let headers = is_response
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
let body = is_response.get("body").cloned();
return (status, headers, body);
}
let status = first
.get("statusCode")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(200) as u16;
let headers = first
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
let body = first.get("body").cloned();
(status, headers, body)
}
#[allow(clippy::type_complexity)]
fn parse_predicates(
predicates: &[serde_json::Value],
) -> (
String,
String,
HashMap<String, String>,
HashMap<String, String>,
Option<String>,
) {
let mut method = "GET".to_string();
let mut path = "/".to_string();
let mut headers = HashMap::new();
let mut query_params = HashMap::new();
let mut body = None;
let mut jsonpath_body: Option<serde_json::Value> = None;
for predicate in predicates {
if let Some(starts_with) = predicate.get("startsWith") {
if let Some(p) = starts_with.get("path").and_then(|v| v.as_str()) {
path = p.to_string();
}
}
}
for predicate in predicates {
if let Some(jsonpath) = predicate.get("jsonpath") {
if let Some(selector) = jsonpath.get("selector").and_then(|v| v.as_str()) {
if let Some(equals) = predicate.get("equals") {
if let Some(value) = equals.get("body") {
let json_value = if let Some(s) = value.as_str() {
serde_json::Value::String(s.to_string())
} else {
value.clone()
};
let new_obj = build_json_from_jsonpath(selector, json_value);
jsonpath_body = Some(match jsonpath_body {
Some(existing) => merge_json_objects(existing, new_obj),
None => new_obj,
});
}
}
}
}
if let Some(equals) = predicate.get("equals") {
let skip_body = predicate.get("jsonpath").is_some();
parse_equals_predicate(
equals,
&mut method,
&mut path,
&mut headers,
&mut query_params,
&mut body,
skip_body,
);
}
if let Some(matches) = predicate.get("matches") {
if let Some(p) = matches.get("path").and_then(|v| v.as_str()) {
path = generate_sample_from_regex(p);
}
if let Some(m) = matches.get("method").and_then(|v| v.as_str()) {
method = generate_sample_from_regex(m);
}
}
if let Some(exists) = predicate.get("exists") {
if let Some(hdrs) = exists.get("headers").and_then(|v| v.as_object()) {
for (name, should_exist) in hdrs {
if should_exist.as_bool().unwrap_or(true) {
headers.insert(name.clone(), "test-value".to_string());
}
}
}
}
if let Some(deep_equals) = predicate.get("deepEquals") {
let skip_body = predicate.get("jsonpath").is_some();
parse_equals_predicate(
deep_equals,
&mut method,
&mut path,
&mut headers,
&mut query_params,
&mut body,
skip_body,
);
}
if let Some(contains) = predicate.get("contains") {
parse_contains_predicate(
contains,
&mut path,
&mut headers,
&mut body,
&mut query_params,
);
}
if let Some(ends_with) = predicate.get("endsWith") {
if let Some(p) = ends_with.get("path").and_then(|v| v.as_str()) {
if !path.ends_with(p) {
if path == "/" {
path = format!("/prefix{p}");
} else if !path.ends_with('/') && !p.starts_with('/') {
path = format!("{path}/{p}");
} else {
path = format!("{path}{p}");
}
}
}
}
if let Some(and_predicates) = predicate.get("and").and_then(|v| v.as_array()) {
let inner: Vec<serde_json::Value> = and_predicates.clone();
let (m, p, h, q, b) = parse_predicates(&inner);
if m != "GET" {
method = m;
}
if p != "/" {
path = p;
}
headers.extend(h);
query_params.extend(q);
if b.is_some() {
body = b;
}
}
if let Some(or_predicates) = predicate.get("or").and_then(|v| v.as_array()) {
if let Some(first) = or_predicates.first() {
let inner = vec![first.clone()];
let (m, p, h, q, b) = parse_predicates(&inner);
if m != "GET" {
method = m;
}
if p != "/" {
path = p;
}
headers.extend(h);
query_params.extend(q);
if b.is_some() {
body = b;
}
}
}
}
if body.is_none() && jsonpath_body.is_some() {
body = jsonpath_body.map(|v| serde_json::to_string(&v).unwrap_or_default());
}
(method, path, headers, query_params, body)
}
fn build_json_from_jsonpath(selector: &str, value: serde_json::Value) -> serde_json::Value {
let path = selector.strip_prefix("$.").unwrap_or(selector);
let parts: Vec<&str> = path.split('.').collect();
let mut result = value;
for part in parts.iter().rev() {
if part.starts_with("[:") || part.starts_with("[") {
result = serde_json::json!([result]);
} else {
let mut obj = serde_json::Map::new();
obj.insert((*part).to_string(), result);
result = serde_json::Value::Object(obj);
}
}
result
}
fn merge_json_objects(
mut base: serde_json::Value,
overlay: serde_json::Value,
) -> serde_json::Value {
if let (serde_json::Value::Object(base_obj), serde_json::Value::Object(overlay_obj)) =
(&mut base, &overlay)
{
for (key, value) in overlay_obj {
if let Some(existing) = base_obj.get_mut(key) {
*existing = merge_json_objects(existing.clone(), value.clone());
} else {
base_obj.insert(key.clone(), value.clone());
}
}
base
} else if let (serde_json::Value::Array(base_arr), serde_json::Value::Array(overlay_arr)) =
(&mut base, &overlay)
{
if !overlay_arr.is_empty() {
if base_arr.is_empty() {
base_arr.extend(overlay_arr.clone());
} else {
let merged = merge_json_objects(base_arr[0].clone(), overlay_arr[0].clone());
base_arr[0] = merged;
}
}
base
} else {
overlay
}
}
fn parse_equals_predicate(
equals: &serde_json::Value,
method: &mut String,
path: &mut String,
headers: &mut HashMap<String, String>,
query_params: &mut HashMap<String, String>,
body: &mut Option<String>,
skip_body: bool,
) {
if let Some(m) = equals.get("method").and_then(|v| v.as_str()) {
*method = m.to_string();
}
if let Some(p) = equals.get("path").and_then(|v| v.as_str()) {
*path = p.to_string();
}
if let Some(hdrs) = equals.get("headers").and_then(|v| v.as_object()) {
for (name, value) in hdrs {
if let Some(v) = value.as_str() {
headers.insert(name.clone(), v.to_string());
}
}
}
if let Some(query) = equals.get("query").and_then(|v| v.as_object()) {
for (name, value) in query {
if let Some(v) = value.as_str() {
query_params.insert(name.clone(), v.to_string());
}
}
}
if !skip_body {
if let Some(b) = equals.get("body") {
if let Some(s) = b.as_str() {
if !s.is_empty() {
*body = Some(s.to_string());
}
} else {
*body = Some(serde_json::to_string(b).unwrap_or_default());
}
}
}
}
fn parse_contains_predicate(
contains: &serde_json::Value,
path: &mut String,
headers: &mut HashMap<String, String>,
body: &mut Option<String>,
query_params: &mut HashMap<String, String>,
) {
if let Some(p) = contains.get("path").and_then(|v| v.as_str()) {
if *path == "/" {
if p.starts_with('/') {
*path = p.to_string();
} else {
*path = format!("/{p}");
}
} else if !path.contains(p) {
if !path.ends_with('/') && !p.starts_with('/') {
path.push('/');
}
path.push_str(p);
}
}
if let Some(query) = contains.get("query").and_then(|v| v.as_object()) {
for (name, value) in query {
if let Some(v) = value.as_str() {
query_params.insert(name.clone(), v.to_string());
}
}
}
if let Some(hdrs) = contains.get("headers").and_then(|v| v.as_object()) {
for (name, value) in hdrs {
if let Some(v) = value.as_str() {
headers.insert(name.clone(), format!("prefix{v}suffix"));
}
}
}
if let Some(b) = contains.get("body").and_then(|v| v.as_str()) {
if let Some(existing) = body {
*body = Some(format!("{existing} {b}"));
} else {
*body = Some(format!("test {b} content"));
}
}
}
fn generate_sample_from_regex(pattern: &str) -> String {
let sample = pattern
.replace(r"[^/]+", "item")
.replace(r"[a-zA-Z]+", "test")
.replace(r"[0-9]+", "123")
.replace(r"[a-z]+", "test")
.replace(r"[A-Z]+", "TEST")
.replace(r"\d+", "1")
.replace(r"\d", "1")
.replace(r"\w+", "test")
.replace(r"\w", "a")
.replace(r".*", "")
.replace(r".+", "x");
let sample = sample.strip_prefix('^').unwrap_or(&sample).to_string();
let sample = sample.strip_suffix('$').unwrap_or(&sample).to_string();
if sample.is_empty() {
"/".to_string()
} else {
sample
}
}
async fn execute_test(
client: &Client,
imposter_port: u16,
test_case: &TestCase,
status_only: bool,
) -> TestResult {
let start = std::time::Instant::now();
let mut url = format!("http://localhost:{}{}", imposter_port, test_case.path);
if !test_case.query_params.is_empty() {
let query_string: Vec<String> = test_case
.query_params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect();
url = format!("{}?{}", url, query_string.join("&"));
}
let mut request = match test_case.method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
_ => client.get(&url),
};
for (name, value) in &test_case.headers {
request = request.header(name, value);
}
if let Some(ref body) = test_case.body {
request = request.body(body.clone());
}
match request.send().await {
Ok(response) => {
let status = response.status().as_u16();
let headers: HashMap<String, String> = response
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| (name.as_str().to_string(), v.to_string()))
})
.collect();
let body_text = response.text().await.ok();
let duration_ms = start.elapsed().as_millis();
let verify_result = if status_only {
verify_response(
test_case.expected_status,
&HashMap::new(), &None, status,
&headers,
&body_text,
false, )
} else {
verify_response(
test_case.expected_status,
&test_case.expected_headers,
&test_case.expected_body,
status,
&headers,
&body_text,
test_case.is_dynamic,
)
};
let success = verify_result.is_success();
let failure_reasons = verify_result.failure_reasons();
TestResult {
test_case: test_case.clone(),
success,
actual_status: Some(status),
actual_headers: Some(headers),
actual_body: body_text,
error: None,
duration_ms,
failure_reasons,
}
}
Err(e) => {
let error_msg = e.to_string();
TestResult {
test_case: test_case.clone(),
success: false,
actual_status: None,
actual_headers: None,
actual_body: None,
error: Some(error_msg.clone()),
duration_ms: start.elapsed().as_millis(),
failure_reasons: vec![FailureReason::RequestError(error_msg)],
}
}
}
}
#[derive(Debug)]
enum VerifyResult {
Success,
Failed(Vec<FailureReason>),
}
impl VerifyResult {
fn is_success(&self) -> bool {
matches!(self, VerifyResult::Success)
}
fn failure_reasons(self) -> Vec<FailureReason> {
match self {
VerifyResult::Success => vec![],
VerifyResult::Failed(reasons) => reasons,
}
}
}
fn verify_response(
expected_status: u16,
expected_headers: &HashMap<String, String>,
expected_body: &Option<serde_json::Value>,
actual_status: u16,
actual_headers: &HashMap<String, String>,
actual_body: &Option<String>,
is_dynamic: bool,
) -> VerifyResult {
let mut failures = Vec::new();
let status_ok = if is_dynamic {
(200..300).contains(&actual_status)
} else {
expected_status == actual_status
};
if !status_ok {
failures.push(FailureReason::StatusMismatch {
expected: expected_status,
actual: actual_status,
});
}
for (name, expected_value) in expected_headers {
let name_lower = name.to_lowercase();
let actual_value = actual_headers
.iter()
.find(|(k, _)| k.to_lowercase() == name_lower)
.map(|(_, v)| v);
match actual_value {
None => {
failures.push(FailureReason::HeaderMissing {
header_name: name.clone(),
});
}
Some(actual) if actual != expected_value => {
failures.push(FailureReason::HeaderMismatch {
header_name: name.clone(),
expected: expected_value.clone(),
actual: actual.clone(),
});
}
_ => {}
}
}
if let Some(expected) = expected_body {
match actual_body {
None => {
failures.push(FailureReason::BodyMissing {
expected: format_json_for_diff(expected),
});
}
Some(actual_text) => {
let expected_normalized = normalize_json_value(expected);
if let Ok(actual_json) = serde_json::from_str::<serde_json::Value>(actual_text) {
if !json_matches(&expected_normalized, &actual_json) {
failures.push(FailureReason::BodyMismatch {
expected: format_json_for_diff(&expected_normalized),
actual: format_json_for_diff(&actual_json),
});
}
} else {
let expected_plain = match &expected_normalized {
serde_json::Value::String(s) => s.clone(),
_ => expected_normalized.to_string(),
};
if actual_text != &expected_plain {
failures.push(FailureReason::BodyMismatch {
expected: expected_plain,
actual: actual_text.clone(),
});
}
}
}
}
}
if failures.is_empty() {
VerifyResult::Success
} else {
VerifyResult::Failed(failures)
}
}
fn format_json_for_diff(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
fn normalize_json_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => {
serde_json::from_str(s).unwrap_or_else(|_| value.clone())
}
_ => value.clone(),
}
}
fn json_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
match (expected, actual) {
(serde_json::Value::Object(exp_obj), serde_json::Value::Object(act_obj)) => {
if exp_obj.len() != act_obj.len() {
return false;
}
exp_obj.iter().all(|(key, exp_val)| {
act_obj
.get(key)
.map(|act_val| json_matches(exp_val, act_val))
.unwrap_or(false)
})
}
(serde_json::Value::Array(exp_arr), serde_json::Value::Array(act_arr)) => {
exp_arr.len() == act_arr.len()
&& exp_arr
.iter()
.zip(act_arr.iter())
.all(|(e, a)| json_matches(e, a))
}
(serde_json::Value::String(exp_str), actual) => {
if let Ok(parsed_exp) = serde_json::from_str::<serde_json::Value>(exp_str) {
json_matches(&parsed_exp, actual)
} else {
expected == actual
}
}
(expected, serde_json::Value::String(act_str)) => {
if let Ok(parsed_act) = serde_json::from_str::<serde_json::Value>(act_str) {
json_matches(expected, &parsed_act)
} else {
expected == actual
}
}
_ => expected == actual,
}
}
fn generate_curl_command(port: u16, test_case: &TestCase) -> String {
let mut cmd = format!("curl -X {} ", test_case.method);
for (name, value) in &test_case.headers {
cmd.push_str(&format!("-H '{name}: {value}' "));
}
if let Some(ref body) = test_case.body {
let escaped = body.replace('\'', "'\\''");
cmd.push_str(&format!("-d '{escaped}' "));
}
let mut url = format!("'http://localhost:{}{}", port, test_case.path);
if !test_case.query_params.is_empty() {
let query_string: Vec<String> = test_case
.query_params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
url = format!("{}?{}", url, query_string.join("&"));
}
url.push('\'');
cmd.push_str(&url);
cmd
}
fn print_summary(summary: &VerificationSummary, show_curl: bool) {
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("{BOLD}Verification Summary{RESET}");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Imposters: {}", summary.total_imposters);
println!(" Stubs: {}", summary.total_stubs);
println!(" Tests: {}", summary.total_tests);
println!();
println!(" {}Passed: {}{}", GREEN, summary.passed, RESET);
println!(" {}Failed: {}{}", RED, summary.failed, RESET);
println!(" {}Skipped: {}{}", YELLOW, summary.skipped, RESET);
println!();
if !summary.failures.is_empty() {
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("{RED}Failure Details{RESET}");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
for (i, failure) in summary.failures.iter().enumerate() {
println!();
println!(
"{}. Imposter :{} {} - Stub #{}{}",
i + 1,
failure.imposter_port,
failure
.imposter_name
.as_ref()
.map(|n| format!("({n})"))
.unwrap_or_default(),
failure.stub_index,
failure
.stub_id
.as_ref()
.map(|id| format!(" [{id}]"))
.unwrap_or_default()
);
println!(" Request: {}", failure.test_description);
println!(" Expected: {}", failure.expected);
println!(" {}Actual: {}{}", RED, failure.actual, RESET);
if show_curl {
if let Some(ref curl) = failure.curl_command {
println!(" Curl: {curl}");
}
}
if !failure.failure_reasons.is_empty() {
println!();
println!(" {BOLD}Why it failed:{RESET}");
for reason in &failure.failure_reasons {
print_failure_reason(reason);
}
}
}
println!();
}
if summary.failed == 0 {
println!("{GREEN}All tests passed!{RESET}");
} else {
println!(
"{}{} test(s) failed. See details above.{}",
RED, summary.failed, RESET
);
}
}
fn print_failure_reason(reason: &FailureReason) {
match reason {
FailureReason::StatusMismatch { expected, actual } => {
println!(" - {YELLOW}Status mismatch:{RESET} expected {GREEN}{expected}{RESET}, got {RED}{actual}{RESET}");
println!(" {DIM}{}{RESET}", reason.hint());
}
FailureReason::HeaderMissing { header_name } => {
println!(" - {YELLOW}Missing header:{RESET} '{header_name}'");
println!(" {DIM}{}{RESET}", reason.hint());
}
FailureReason::HeaderMismatch {
header_name,
expected,
actual,
} => {
println!(" - {YELLOW}Header mismatch:{RESET} '{header_name}'");
println!(" Expected: {GREEN}\"{expected}\"{RESET}");
println!(" Actual: {RED}\"{actual}\"{RESET}");
}
FailureReason::BodyMissing { expected } => {
println!(" - {YELLOW}Missing body:{RESET} expected response body but got none");
println!(" {DIM}{}{RESET}", reason.hint());
println!(" Expected body:");
for line in expected.lines().take(10) {
println!(" {GREEN}{line}{RESET}");
}
if expected.lines().count() > 10 {
println!(
" {DIM}... ({} more lines){RESET}",
expected.lines().count() - 10
);
}
}
FailureReason::BodyMismatch { expected, actual } => {
println!(" - {YELLOW}Body mismatch:{RESET}");
println!(" {DIM}{}{RESET}", reason.hint());
print_diff(expected, actual);
}
FailureReason::RequestError(err) => {
println!(" - {YELLOW}Request error:{RESET} {err}");
println!(" {DIM}{}{RESET}", reason.hint());
}
}
}
fn print_diff(expected: &str, actual: &str) {
println!(" {DIM}Diff ({GREEN}-expected{DIM}, {RED}+actual{DIM}):{RESET}");
let diff = TextDiff::from_lines(expected, actual);
for change in diff.iter_all_changes() {
let (sign, color) = match change.tag() {
ChangeTag::Delete => ("-", GREEN),
ChangeTag::Insert => ("+", RED),
ChangeTag::Equal => (" ", RESET),
};
if change.tag() == ChangeTag::Equal {
print!(
" {DIM}{sign} {}{RESET}",
change.value().trim_end_matches('\n')
);
} else {
print!(
" {color}{sign} {}{RESET}",
change.value().trim_end_matches('\n')
);
}
println!();
}
}
fn demo_enhanced_error_output() {
println!("{BOLD}{CYAN}Enhanced Error Reporting Demo{RESET}");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
println!("{BOLD}1. Status Code Mismatch:{RESET}");
let status_fail = FailureReason::StatusMismatch {
expected: 200,
actual: 404,
};
print_failure_reason(&status_fail);
println!();
println!("{BOLD}2. Missing Header:{RESET}");
let header_missing = FailureReason::HeaderMissing {
header_name: "X-Request-Id".to_string(),
};
print_failure_reason(&header_missing);
println!();
println!("{BOLD}3. Header Value Mismatch:{RESET}");
let header_mismatch = FailureReason::HeaderMismatch {
header_name: "Content-Type".to_string(),
expected: "application/json".to_string(),
actual: "text/plain".to_string(),
};
print_failure_reason(&header_mismatch);
println!();
println!("{BOLD}4. JSON Body Mismatch (with diff):{RESET}");
let expected_json = r#"{
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
],
"total": 2
}"#;
let actual_json = r#"{
"users": [
{"id": 1, "name": "Alice"},
{"id": 3, "name": "Charlie"}
],
"total": 2,
"extra": "unexpected"
}"#;
let body_mismatch = FailureReason::BodyMismatch {
expected: expected_json.to_string(),
actual: actual_json.to_string(),
};
print_failure_reason(&body_mismatch);
println!();
println!("{BOLD}5. Connection Error:{RESET}");
let conn_error = FailureReason::RequestError("Connection refused (os error 61)".to_string());
print_failure_reason(&conn_error);
println!();
println!("{BOLD}6. Missing Response Body:{RESET}");
let body_missing = FailureReason::BodyMissing {
expected: r#"{"status": "ok"}"#.to_string(),
};
print_failure_reason(&body_missing);
println!();
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("{GREEN}Demo complete!{RESET}");
}