Skip to content

Commit 14508f6

Browse files
authored
Merge branch 'main' into codex/fix-missing-readme.md-for-publish
2 parents 1d9198f + 09ea496 commit 14508f6

File tree

5 files changed

+108
-15
lines changed

5 files changed

+108
-15
lines changed

CHANGELOG.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ All notable changes to this project will be documented in this file.
99
- Published the shared template parser crate so `masterror-derive` no longer
1010
depends on a workspace-only package when uploaded to crates.io.
1111

12+
### Documentation
13+
- Added a dedicated README for `masterror-template` describing installation,
14+
parsing examples and formatter metadata for crates.io readers.
15+
### Tests
16+
- Added regression coverage for long classifier needles to exercise the
17+
heap-allocation fallback.
18+
1219
### Changed
20+
- Added an owning `From<AppError>` conversion for `ErrorResponse` and updated the
21+
Axum adapter to use it, eliminating redundant clones when building HTTP error
22+
bodies.
23+
- Precomputed lowercase Turnkey classifier needles with a stack-backed buffer
24+
to remove repeated transformations while keeping the common zero-allocation
25+
path for short patterns.
1326
- Bumped `masterror-derive` to `0.6.6` and `masterror-template` to `0.3.6` so
1427
downstream users rely on the newly published parser crate.
1528

16-
### Documentation
17-
- Added a dedicated README for `masterror-template` describing installation,
18-
parsing examples and formatter metadata for crates.io readers.
1929

2030
## [0.10.6] - 2025-09-21
2131

src/convert/axum.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ impl IntoResponse for AppError {
7777
#[cfg(feature = "serde_json")]
7878
{
7979
// Build the stable wire contract (includes `code`).
80-
let body: ErrorResponse = (&self).into();
80+
let body: ErrorResponse = self.into();
8181
return body.into_response();
8282
}
8383

src/response/mapping.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,33 @@ impl Display for ErrorResponse {
1313
}
1414
}
1515

16+
impl From<AppError> for ErrorResponse {
17+
fn from(err: AppError) -> Self {
18+
let AppError {
19+
kind,
20+
message,
21+
retry,
22+
www_authenticate
23+
} = err;
24+
25+
let status = kind.http_status();
26+
let code = AppCode::from(kind);
27+
let message = match message {
28+
Some(msg) => msg.into_owned(),
29+
None => String::from("An error occurred")
30+
};
31+
32+
Self {
33+
status,
34+
code,
35+
message,
36+
details: None,
37+
retry,
38+
www_authenticate
39+
}
40+
}
41+
}
42+
1643
impl From<&AppError> for ErrorResponse {
1744
fn from(err: &AppError) -> Self {
1845
let status = err.kind.http_status();

src/response/tests.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ fn from_app_error_uses_default_message_when_none() {
147147
assert_eq!(e.message, "An error occurred");
148148
}
149149

150+
#[test]
151+
fn from_owned_app_error_moves_message_and_metadata() {
152+
let err = AppError::unauthorized(String::from("owned message"))
153+
.with_retry_after_secs(5)
154+
.with_www_authenticate("Bearer");
155+
156+
let resp: ErrorResponse = err.into();
157+
158+
assert_eq!(resp.status, 401);
159+
assert!(matches!(resp.code, AppCode::Unauthorized));
160+
assert_eq!(resp.message, "owned message");
161+
assert_eq!(resp.retry.unwrap().after_seconds, 5);
162+
assert_eq!(resp.www_authenticate.as_deref(), Some("Bearer"));
163+
}
164+
165+
#[test]
166+
fn from_owned_app_error_defaults_message_when_absent() {
167+
let resp: ErrorResponse = AppError::bare(AppErrorKind::Internal).into();
168+
169+
assert_eq!(resp.status, 500);
170+
assert!(matches!(resp.code, AppCode::Internal));
171+
assert_eq!(resp.message, "An error occurred");
172+
}
173+
150174
// --- Display formatting --------------------------------------------------
151175

152176
#[test]

src/turnkey/classifier.rs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use super::domain::TurnkeyErrorKind;
22

3+
const STACK_NEEDLE_INLINE_CAP: usize = 64;
4+
35
/// Heuristic classifier for raw SDK/provider messages (ASCII case-insensitive).
46
///
5-
/// This helper **does not allocate**; it performs case-insensitive `contains`
6-
/// checks over the input string to map common upstream texts to stable kinds.
7+
/// This helper keeps allocations to a minimum; it performs case-insensitive
8+
/// `contains` checks over the input string to map common upstream texts to
9+
/// stable kinds while reusing stack buffers for the short ASCII patterns we
10+
/// match.
711
///
812
/// The classifier is intentionally minimal; providers can and will change
913
/// messages. Prefer returning structured errors from adapters whenever
@@ -55,20 +59,41 @@ pub fn classify_turnkey_error(msg: &str) -> TurnkeyErrorKind {
5559
}
5660

5761
/// Returns true if `haystack` contains `needle` ignoring ASCII case.
58-
/// Performs the search without allocating.
62+
///
63+
/// The search avoids heap allocations for needles up to
64+
/// `STACK_NEEDLE_INLINE_CAP` bytes by reusing a stack buffer. Longer needles
65+
/// allocate once to store their lowercased representation.
5966
#[inline]
6067
fn contains_nocase(haystack: &str, needle: &str) -> bool {
6168
// Fast path: empty needle always matches.
6269
if needle.is_empty() {
6370
return true;
6471
}
65-
// Walk haystack windows and compare ASCII case-insensitively.
66-
haystack.as_bytes().windows(needle.len()).any(|w| {
67-
w.iter()
68-
.copied()
69-
.map(ascii_lower)
70-
.eq(needle.as_bytes().iter().copied().map(ascii_lower))
71-
})
72+
let haystack_bytes = haystack.as_bytes();
73+
let needle_bytes = needle.as_bytes();
74+
75+
let search = |needle_lower: &[u8]| {
76+
haystack_bytes.windows(needle_lower.len()).any(|window| {
77+
window
78+
.iter()
79+
.zip(needle_lower.iter())
80+
.all(|(hay, lower_needle)| ascii_lower(*hay) == *lower_needle)
81+
})
82+
};
83+
84+
if needle_bytes.len() <= STACK_NEEDLE_INLINE_CAP {
85+
let mut inline = [0u8; STACK_NEEDLE_INLINE_CAP];
86+
for (idx, byte) in needle_bytes.iter().enumerate() {
87+
inline[idx] = ascii_lower(*byte);
88+
}
89+
search(&inline[..needle_bytes.len()])
90+
} else {
91+
let mut lowercased = Vec::with_capacity(needle_bytes.len());
92+
for byte in needle_bytes {
93+
lowercased.push(ascii_lower(*byte));
94+
}
95+
search(lowercased.as_slice())
96+
}
7297
}
7398

7499
/// Check whether `haystack` contains any of the `needles` (ASCII
@@ -90,10 +115,17 @@ pub(super) mod internal_tests {
90115
use super::*;
91116

92117
#[test]
93-
fn contains_nocase_works_without_alloc() {
118+
fn contains_nocase_matches_ascii_case_insensitively() {
94119
assert!(contains_nocase("ABCdef", "cDe"));
95120
assert!(contains_any_nocase("hello world", &["nope", "WORLD"]));
96121
assert!(!contains_nocase("rustacean", "python"));
97122
assert!(contains_nocase("", ""));
98123
}
124+
125+
#[test]
126+
fn contains_nocase_handles_long_needles() {
127+
let haystack = "prefixed".to_owned() + &"A".repeat(128) + "suffix";
128+
let needle = "a".repeat(128);
129+
assert!(contains_nocase(&haystack, &needle));
130+
}
99131
}

0 commit comments

Comments
 (0)