use crate::assert::types::StepResult;
pub const ROUTE_ORDERING_HINT: &str = "note: the server may have matched this path to a dynamic route (e.g. /foo/:id); check for route ordering conflicts (see docs/TROUBLESHOOTING.md#route-ordering).";
pub fn route_ordering_hint(url: &str, response_body: &str) -> Option<String> {
if response_body.trim().is_empty() {
return None;
}
let segments = extract_path_segments(url);
if segments.len() < 2 {
return None;
}
let body_lower = response_body.to_ascii_lowercase();
if has_route_ordering_signal(&body_lower, &segments) {
Some(ROUTE_ORDERING_HINT.to_string())
} else {
None
}
}
fn has_route_ordering_signal(body_lower: &str, segments: &[String]) -> bool {
let direct_signals = [
"route not found",
"invalid id",
"invalid uuid",
"cannot parse",
"could not parse",
"failed to parse",
"validation failed",
];
if direct_signals.iter().any(|sig| body_lower.contains(sig)) {
return true;
}
if body_lower.contains("param") {
for segment in segments {
if segment.is_empty() {
continue;
}
if segment.len() < 3 {
continue;
}
let segment_lower = segment.to_ascii_lowercase();
if body_lower.contains(&segment_lower) {
return true;
}
}
}
false
}
fn extract_path_segments(url: &str) -> Vec<String> {
let path = strip_scheme_and_authority(url);
let path = path.split('?').next().unwrap_or("");
let path = path.split('#').next().unwrap_or("");
path.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
fn strip_scheme_and_authority(url: &str) -> &str {
if let Some(idx) = url.find("://") {
let after_scheme = &url[idx + 3..];
if let Some(path_idx) = after_scheme.find('/') {
&after_scheme[path_idx..]
} else {
""
}
} else {
url
}
}
pub fn step_hints(step: &StepResult) -> Vec<String> {
if step.passed {
return Vec::new();
}
let Some(request) = step.request_info.as_ref() else {
return Vec::new();
};
let Some(response) = step.response_info.as_ref() else {
return Vec::new();
};
if !is_2xx_expected_4xx_actual(step, response.status) {
return Vec::new();
}
let body_text = response
.body
.as_ref()
.map(|body| match body {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_default();
let mut hints = Vec::new();
if let Some(hint) = route_ordering_hint(&request.url, &body_text) {
hints.push(hint);
}
hints
}
fn is_2xx_expected_4xx_actual(step: &StepResult, actual_status: u16) -> bool {
if !(400..500).contains(&actual_status) {
return false;
}
step.assertion_results
.iter()
.filter(|a| !a.passed && a.assertion == "status")
.any(|a| status_expectation_implies_2xx(&a.expected))
}
fn status_expectation_implies_2xx(expected: &str) -> bool {
let trimmed = expected.trim();
if trimmed.is_empty() {
return false;
}
if let Ok(code) = trimmed.parse::<u16>() {
return (200..300).contains(&code);
}
let lower = trimmed.to_ascii_lowercase();
if lower == "2xx" {
return true;
}
if lower.starts_with("in [") && !lower.contains("<") && !lower.contains(">") {
let digit_runs = extract_u16_runs(trimmed);
if !digit_runs.is_empty() {
return digit_runs.iter().all(|code| (200..300).contains(code));
}
}
let (lower_bound, upper_bound) = collect_range_bounds(trimmed);
if let (Some(lo), Some(hi)) = (lower_bound, upper_bound) {
return lo >= 200 && hi <= 299;
}
false
}
fn collect_range_bounds(input: &str) -> (Option<u16>, Option<u16>) {
let mut lower: Option<u16> = None;
let mut upper: Option<u16> = None;
for part in input.split(',').map(|s| s.trim()) {
if let Some(rest) = part.strip_prefix(">=") {
if let Ok(v) = rest.trim().parse::<u16>() {
lower = Some(lower.map_or(v, |cur| cur.max(v)));
}
} else if let Some(rest) = part.strip_prefix('>') {
if let Ok(v) = rest.trim().parse::<u16>() {
let v_inclusive = v.saturating_add(1);
lower = Some(lower.map_or(v_inclusive, |cur| cur.max(v_inclusive)));
}
} else if let Some(rest) = part.strip_prefix("<=") {
if let Ok(v) = rest.trim().parse::<u16>() {
upper = Some(upper.map_or(v, |cur| cur.min(v)));
}
} else if let Some(rest) = part.strip_prefix('<') {
if let Ok(v) = rest.trim().parse::<u16>() {
let v_inclusive = v.saturating_sub(1);
upper = Some(upper.map_or(v_inclusive, |cur| cur.min(v_inclusive)));
}
}
}
(lower, upper)
}
fn extract_u16_runs(input: &str) -> Vec<u16> {
let mut out = Vec::new();
let mut current = String::new();
for ch in input.chars() {
if ch.is_ascii_digit() {
current.push(ch);
} else if !current.is_empty() {
if let Ok(v) = current.parse::<u16>() {
out.push(v);
}
current.clear();
}
}
if !current.is_empty() {
if let Ok(v) = current.parse::<u16>() {
out.push(v);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emits_hint_on_invalid_uuid_body() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"statusCode":400,"message":"Validation failed (uuid is expected)","error":"Bad Request"}"#;
let hint = route_ordering_hint(url, body).expect("hint should fire");
assert_eq!(hint, ROUTE_ORDERING_HINT);
}
#[test]
fn emits_hint_on_invalid_id_body() {
let url = "http://api.example.com/users/me";
let body = r#"{"error":"Invalid id"}"#;
assert_eq!(
route_ordering_hint(url, body),
Some(ROUTE_ORDERING_HINT.to_string()),
);
}
#[test]
fn emits_hint_on_route_not_found_body() {
let url = "http://api.example.com/foo/approve";
let body = r#"{"message":"Route not found"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn emits_hint_on_cannot_parse_body() {
let url = "http://api.example.com/foo/approve";
let body = "cannot parse value as integer";
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn emits_hint_on_failed_to_parse_body() {
let url = "http://api.example.com/foo/approve";
let body = r#"{"detail":"failed to parse path parameter"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn emits_hint_on_param_error_mentioning_segment() {
let url = "http://api.example.com/orders/approve";
let body =
r#"{"message":"param 'approve' must be a valid ObjectId","error":"Bad Request"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn emits_hint_case_insensitive() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"Error":"INVALID UUID"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn works_with_relative_url() {
let url = "/orders/approve";
let body = r#"{"message":"Validation failed"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn works_with_query_string_and_fragment() {
let url = "http://api.example.com/orders/approve?tenant=1#frag";
let body = r#"{"message":"invalid uuid"}"#;
assert!(route_ordering_hint(url, body).is_some());
}
#[test]
fn no_hint_on_generic_not_found_body() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"statusCode":404,"message":"Not Found"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_empty_body() {
let url = "http://api.example.com/orders/approve";
assert_eq!(route_ordering_hint(url, ""), None);
assert_eq!(route_ordering_hint(url, " \n "), None);
}
#[test]
fn no_hint_on_single_segment_path() {
let url = "http://api.example.com/health";
let body = r#"{"message":"invalid uuid"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_root_path() {
let url = "http://api.example.com/";
let body = r#"{"message":"invalid uuid"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_success_body_shape() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"status":"ok","id":"abc"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_param_without_segment_match() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"message":"missing required param 'tenant'"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_unrelated_error_body() {
let url = "http://api.example.com/orders/approve";
let body = r#"{"message":"Insufficient permissions"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn no_hint_on_very_short_segment_coincidence() {
let url = "http://api.example.com/users/me";
let body = r#"{"message":"missing required param 'tenant' for endpoint"}"#;
assert_eq!(route_ordering_hint(url, body), None);
}
#[test]
fn extract_segments_absolute_url() {
let segs = extract_path_segments("http://api.example.com/foo/bar");
assert_eq!(segs, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn extract_segments_strips_query() {
let segs = extract_path_segments("http://api.example.com/foo/bar?x=1&y=2");
assert_eq!(segs, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn extract_segments_strips_fragment() {
let segs = extract_path_segments("http://api.example.com/foo/bar#section");
assert_eq!(segs, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn extract_segments_relative_path() {
let segs = extract_path_segments("/foo/bar");
assert_eq!(segs, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn extract_segments_empty_for_bare_host() {
assert!(extract_path_segments("http://api.example.com").is_empty());
assert!(extract_path_segments("http://api.example.com/").is_empty());
}
#[test]
fn status_expectation_2xx_forms() {
assert!(status_expectation_implies_2xx("200"));
assert!(status_expectation_implies_2xx("201"));
assert!(status_expectation_implies_2xx("204"));
assert!(status_expectation_implies_2xx("2xx"));
assert!(status_expectation_implies_2xx("2XX"));
assert!(status_expectation_implies_2xx("in [200, 201, 204]"));
assert!(status_expectation_implies_2xx(">= 200, < 300"));
assert!(status_expectation_implies_2xx("> 199, <= 299"));
}
#[test]
fn status_expectation_non_2xx_forms() {
assert!(!status_expectation_implies_2xx(""));
assert!(!status_expectation_implies_2xx("404"));
assert!(!status_expectation_implies_2xx("4xx"));
assert!(!status_expectation_implies_2xx("in [200, 500]"));
assert!(!status_expectation_implies_2xx(">= 200"));
assert!(!status_expectation_implies_2xx("< 500"));
assert!(!status_expectation_implies_2xx(">= 200, < 500"));
assert!(!status_expectation_implies_2xx(">= 400, < 500"));
}
use crate::assert::types::{
AssertionResult, FailureCategory, RequestInfo, ResponseInfo, StepResult,
};
use std::collections::HashMap;
fn failing_step(
expected: &str,
actual_status: u16,
url: &str,
body: Option<serde_json::Value>,
) -> StepResult {
StepResult {
name: "call".into(),
description: None,
debug: false,
passed: false,
duration_ms: 10,
assertion_results: vec![AssertionResult::fail(
"status",
expected,
actual_status.to_string(),
format!("Expected HTTP status {}, got {}", expected, actual_status),
)],
request_info: Some(RequestInfo {
method: "POST".into(),
url: url.into(),
headers: HashMap::new(),
body: None,
multipart: None,
}),
response_info: Some(ResponseInfo {
status: actual_status,
headers: HashMap::new(),
body,
}),
error_category: Some(FailureCategory::AssertionFailed),
response_status: Some(actual_status),
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}
}
#[test]
fn step_hints_emitted_for_2xx_expected_4xx_actual_with_signal() {
let body = serde_json::json!({"message": "Validation failed (uuid is expected)"});
let step = failing_step("201", 400, "http://api/orders/approve", Some(body));
let hints = step_hints(&step);
assert_eq!(hints, vec![ROUTE_ORDERING_HINT.to_string()]);
}
#[test]
fn step_hints_skipped_when_step_passed() {
let body = serde_json::json!({"message": "invalid uuid"});
let mut step = failing_step("201", 400, "http://api/orders/approve", Some(body));
step.passed = true;
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_skipped_when_no_request_info() {
let body = serde_json::json!({"message": "invalid uuid"});
let mut step = failing_step("201", 400, "http://api/orders/approve", Some(body));
step.request_info = None;
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_skipped_when_no_response_info() {
let mut step = failing_step("201", 400, "http://api/orders/approve", None);
step.response_info = None;
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_skipped_when_expected_non_2xx() {
let body = serde_json::json!({"message": "invalid uuid"});
let step = failing_step("404", 400, "http://api/orders/approve", Some(body));
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_skipped_when_actual_is_5xx() {
let body = serde_json::json!({"message": "invalid uuid"});
let step = failing_step("200", 500, "http://api/orders/approve", Some(body));
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_skipped_when_body_lacks_signal() {
let body = serde_json::json!({"message": "Not Found"});
let step = failing_step("200", 404, "http://api/orders/approve", Some(body));
assert!(step_hints(&step).is_empty());
}
#[test]
fn step_hints_handles_string_body() {
let body = serde_json::Value::String("Validation failed".into());
let step = failing_step("200", 400, "http://api/orders/approve", Some(body));
let hints = step_hints(&step);
assert_eq!(hints, vec![ROUTE_ORDERING_HINT.to_string()]);
}
#[test]
fn step_hints_ignores_passing_status_assertion() {
let body = serde_json::json!({"message": "invalid uuid"});
let mut step = failing_step("200", 400, "http://api/orders/approve", Some(body));
step.assertion_results = vec![
AssertionResult::pass("status", "2xx", "200"),
AssertionResult::fail("body $.name", "Alice", "Bob", "body mismatch"),
];
assert!(step_hints(&step).is_empty());
}
}