Files
Oxicloud/src/common/text.rs
T
Claude 867e1fe259 perf: round 20 — iCal/vCard parse allocs, owned-DTO moves, Result-collect pre-size, NC etag/favorites emit
Benchmark-gated (benches/ROUND20.md), same rule as rounds 2-19: every change
ships with a BEFORE/AFTER counting-allocator micro-benchmark and a byte-value
equivalence gate; a non-winning AFTER is rolled back (never applied). The
rollback rule is encoded in the harness (GATE FAIL exit). All 8 sections pass.

Reproduce: cargo run --release --features bench --example bench_round20_micro

- A1 CalendarEvent iCal parse: replace the throwaway per-property
  HashMap<String,Vec<String>> (DTSTART/DTEND/RECURRENCE-ID) with a direct
  VALUE=DATE scan; prop_with_params kept #[cfg(test)] (6->2 allocs/event, 4.2x)
- A2 UserDto::from: add User::into_parts and MOVE image (<=512 KiB data URI)
  + ui_preferences JSON instead of cloning on every /api/auth/me (27->14 allocs)
- A3 parse_vcard: drop the per-line to_ascii_uppercase copy + the lines Vec;
  promote ascii_ci_contains to common::text and share it (8->1 allocs/contact)
- A4 Calendar/AddressBook DTO: into_parts move incl. custom_properties map (18->10)
- I1 file-listing repos: collect::<Result<Vec>>() size-hints to 0 and grows from
  capacity 0; pre-size with Vec::with_capacity (8->1 container reallocs, 4 sites)
- I4 plaintext_stream: lazy emit iterator instead of eager Vec collect (43x wall)
- C1 NC write_etag_element: borrowed pre-escaped quote events, no owned quoted
  String/escape re-alloc; byte-identical output (3->0 allocs/PROPFIND row)
- C3 NC favorites REPORT: map.remove() move instead of get().clone() (~7 allocs/fav)

Deferred (documented in ROUND20.md): NC oc:id/trashbin buffer reuse, I1 sibling
CardDAV/CalDAV listing paths, Contact JSONB Json<Vec<_>> decode, dedup
settle_batch &str bind, and a fast DoS-resistant hasher for hot trusted-key maps
(needs a dependency decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsJjcVX9RoN96DMa35Wqzd
2026-07-20 00:18:54 +00:00

55 lines
1.8 KiB
Rust

//! Small allocation-free text predicates shared across the hot parse paths.
/// ASCII case-insensitive substring test — the allocation-free equivalent of
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
///
/// Callers pass an already-upper/lower-cased `needle` and get the same boolean
/// `haystack.to_ascii_uppercase().contains(NEEDLE)` would, without the
/// throwaway per-call `String`. Used by the search name-match classifier and by
/// `ContactService::parse_vcard`'s per-line `TYPE=` routing
/// (benches/ROUND20.md §A3).
pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_uppercase_contains() {
// Parity with the `to_ascii_uppercase().contains(NEEDLE)` shape it
// replaced, across mixed case and the empty/oversize edge cases.
let cases: &[(&str, &str)] = &[
("EMAIL;TYPE=home:a@b.com", "TYPE=HOME"),
("EMAIL;type=Work:a@b.com", "TYPE=WORK"),
("TEL;TYPE=CELL:+1", "TYPE=CELL"),
("TEL;TYPE=voice:+1", "TYPE=CELL"),
("ADR;TYPE=Home:;;x", "TYPE=WORK"),
("", "TYPE=HOME"),
("short", "a-very-long-needle"),
];
for (hay, needle) in cases {
let reference = hay.to_ascii_uppercase().contains(needle);
assert_eq!(
ascii_ci_contains(hay.as_bytes(), needle.as_bytes()),
reference,
"mismatch for haystack={hay:?} needle={needle:?}"
);
}
}
#[test]
fn empty_needle_is_true() {
assert!(ascii_ci_contains(b"anything", b""));
}
}