Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/api-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: API compatibility

on:
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
minor-release:
runs-on: ubuntu-latest
steps:
- name: Checkout complete history
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- name: Set up stable Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
toolchain: stable

- name: Install semver checker
run: cargo install cargo-semver-checks --version 0.50.0 --locked

- name: Refresh main baseline
run: git fetch --no-tags origin main:refs/remotes/origin/main

- name: Verify minor-release compatibility
run: >-
cargo semver-checks check-release
--baseline-rev origin/main
--all-features
--release-type minor
61 changes: 61 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# API compatibility

CheetahString 3.1 keeps the existing 3.x source surface while tightening the
private implementation. Public methods, traits, variants, aliases, and return
types are not removed or renamed in this release line.

## Pattern contract

`starts_with`, `ends_with`, and `contains` accept the sealed `StrPattern` types:

| Pattern | Meaning |
|---|---|
| `char` | one Unicode scalar value |
| `&str` | borrowed string pattern |
| `&String` | borrowed owned string pattern |

External `StrPattern` implementations are rejected by the sealed supertrait.
Query methods classify supported values through a private enum. The hidden
`StrPattern::as_str_pattern` method and its dispatch value remain callable only
as 3.1 compatibility surfaces; the query implementation does not depend on
them.

`split_char` exposes a double-ended standard iterator. `split_str` remains
forward-only because standard string-pattern splitting cannot guarantee reverse
iteration. These capabilities are expressed by the return types rather than by
a runtime panic path.

## Error contract

The operations intentionally return the most precise existing error type:

| Operation | Error type | Buffer recovery |
|---|---|---|
| `try_from_bytes`, `try_from_vec`, `try_from_arc_vec` | `core::str::Utf8Error` | No |
| `try_from_bytes_buf` | `core::str::Utf8Error` | No |
| `try_copy_from_bytes` | `FromUtf8BytesError` | Yes, via `into_bytes` or `into_parts` |
| `CheetahBytes::try_into_string` | `core::str::Utf8Error` | No |
| `try_substring` | `cheetah_string::Error` through `Result` | Not applicable |

`Error::Utf8Error` and `From<Utf8Error>` remain available for existing callers
that aggregate errors into the crate's compatibility error type. UTF-8
constructors continue to return `Utf8Error` directly; 3.1 does not rewrite their
signatures. Range variants remain exhaustive and unchanged for existing match
expressions.

## Automated compatibility gate

`.github/workflows/api-compatibility.yml` runs `cargo-semver-checks` with
minor-release rules against `origin/main` and all features. The repository also
compiles a downstream-style test that calls the hidden compatibility method and
checks the exact constructor and substring result types.

Local reproduction:

```bash
cargo semver-checks check-release \
--baseline-rev origin/main \
--all-features \
--release-type minor
cargo test --test api_contract --all-features
```
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@
64-bit targets while preserving the 23-byte inline capacity. The stable
representation uses constrained Rust enum discriminants as layout niches and
does not encode pointers as integers.
- Moved text-query pattern dispatch behind a private sealed classifier while
retaining the 3.1 compatibility method and dispatch value unchanged.
- Added downstream error-signature contracts and an automated minor-release
semver comparison against the main branch.

### Migration

- Replace read-only `PackedCheetahString` values with `CheetahString`.
- Replace append-heavy uses with `CheetahBuilder` or `String`, freezing to
`CheetahString` only after mutation completes.
- Code that relied on a 24-byte object has no safe drop-in replacement and
must re-evaluate its container-memory requirements.
- Code that relied on the retired mutable packed API must still migrate its
mutation flow; the stable immutable value now provides a safe 24-byte layout.

### Compatibility

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,9 @@ Optional features do not change the stable `CheetahString` layout.

The former packed v1 type was removed in 3.1 because its heap representation
round-tripped an allocation pointer through `usize`, which strict-provenance
Miri rejected. There is no safe 24-byte drop-in replacement. Use
`CheetahString` for immutable text or `CheetahBuilder`/`String` while mutation
continues.
Miri rejected. The stable immutable `CheetahString` now reaches 24 bytes through
safe Rust enum niches, but it is not a mutable `PackedCheetahString` drop-in.
Use `CheetahBuilder` or `String` while mutation continues.

## Performance evidence

Expand Down Expand Up @@ -218,6 +218,10 @@ libFuzzer target with AddressSanitizer on pull requests and on a weekly
schedule. See [Safety model](SAFETY.md) for the maintained unsafe-boundary
inventory and local verification commands.

Pattern and error signatures follow the source-compatible 3.1 policy described
in [API compatibility](API.md). A dedicated workflow compares every pull request
with `origin/main` under minor-release semver rules.

## Projects using CheetahString

- [RocketMQ Rust](https://github.com/mxsm/rocketmq-rust)
Expand Down
14 changes: 14 additions & 0 deletions scripts/tests/test_repository_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ def test_compact_layout_does_not_integerize_or_reconstruct_pointers(self) -> Non
):
self.assertNotIn(forbidden, source)

def test_pattern_dispatch_is_private_with_a_compatibility_shim(self) -> None:
pattern = read("src/cheetah_string/pattern.rs")
query = read("src/cheetah_string/query.rs")
self.assertIn("pub(super) fn classify", pattern)
self.assertNotIn(".as_str_pattern()", query)
self.assertIn("fn as_str_pattern", pattern)
self.assertIn("pub enum StrPatternImpl", pattern)

def test_minor_release_semver_workflow_is_present(self) -> None:
workflow = read(".github/workflows/api-compatibility.yml")
self.assertIn("cargo-semver-checks --version 0.50.0 --locked", workflow)
self.assertIn("--baseline-rev origin/main", workflow)
self.assertIn("--release-type minor", workflow)

def test_workflow_actions_are_immutable(self) -> None:
for workflow in sorted(WORKFLOWS.glob("*.y*ml")):
text = workflow.read_text(encoding="utf-8")
Expand Down
81 changes: 57 additions & 24 deletions src/cheetah_string/pattern.rs
Original file line number Diff line number Diff line change
@@ -1,51 +1,84 @@
use alloc::string::String;
use core::str;

pub enum StrPatternKind<'a> {
Char(char),
Str(&'a str),
}
Comment on lines +4 to +7

// Sealed trait pattern to support both &str and char in starts_with/ends_with/contains.
mod private {
use alloc::string::String;

pub trait Sealed {}
impl Sealed for char {}
impl Sealed for &str {}
impl Sealed for &String {}
pub trait Sealed {
fn classify(&self) -> super::StrPatternKind<'_>;
}

impl Sealed for char {
#[inline]
fn classify(&self) -> super::StrPatternKind<'_> {
super::StrPatternKind::Char(*self)
}
}

impl Sealed for &str {
#[inline]
fn classify(&self) -> super::StrPatternKind<'_> {
super::StrPatternKind::Str(self)
}
}

impl Sealed for &String {
#[inline]
fn classify(&self) -> super::StrPatternKind<'_> {
super::StrPatternKind::Str(self.as_str())
}
}

pub trait SplitSealed {}
impl SplitSealed for char {}
impl SplitSealed for &str {}
}

/// A pattern that can be used with `starts_with` and `ends_with` methods.
/// A sealed pattern accepted by text query methods.
///
/// The supported pattern types are `char`, `&str`, and `&String`. External
/// implementations are intentionally rejected so the crate can preserve query
/// semantics across compatible releases.
///
/// ```compile_fail
/// use cheetah_string::StrPattern;
///
/// struct ExternalPattern;
/// impl StrPattern for ExternalPattern {}
/// ```
pub trait StrPattern: private::Sealed {
#[doc(hidden)]
fn as_str_pattern(&self) -> StrPatternImpl<'_>;
fn as_str_pattern(&self) -> StrPatternImpl<'_> {
match private::Sealed::classify(self) {
StrPatternKind::Char(value) => StrPatternImpl::Char(value),
StrPatternKind::Str(value) => StrPatternImpl::Str(value),
}
}
}

/// Compatibility dispatch value returned by [`StrPattern::as_str_pattern`].
///
/// Query implementation uses a private classifier; this type remains unchanged
/// so existing 3.1 source continues to compile.
#[doc(hidden)]
pub enum StrPatternImpl<'a> {
Char(char),
Str(&'a str),
}

impl StrPattern for char {
#[inline]
fn as_str_pattern(&self) -> StrPatternImpl<'_> {
StrPatternImpl::Char(*self)
}
}
impl StrPattern for char {}
impl StrPattern for &str {}
impl StrPattern for &String {}

impl StrPattern for &str {
#[inline]
fn as_str_pattern(&self) -> StrPatternImpl<'_> {
StrPatternImpl::Str(self)
}
}

impl StrPattern for &String {
#[inline]
fn as_str_pattern(&self) -> StrPatternImpl<'_> {
StrPatternImpl::Str(self.as_str())
}
#[inline]
pub(super) fn classify<P: StrPattern>(pattern: &P) -> StrPatternKind<'_> {
private::Sealed::classify(pattern)
}

/// A compatibility pattern whose iterator type exposes its capabilities.
Expand Down
20 changes: 10 additions & 10 deletions src/cheetah_string/query.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use core::str;

use super::pattern::{SplitPattern, SplitStr, StrPattern, StrPatternImpl};
use super::pattern::{classify, SplitPattern, SplitStr, StrPattern, StrPatternKind};
use super::CheetahString;

impl CheetahString {
Expand All @@ -23,9 +23,9 @@ impl CheetahString {
/// ```
#[inline]
pub fn starts_with<P: StrPattern>(&self, pat: P) -> bool {
match pat.as_str_pattern() {
StrPatternImpl::Char(c) => self.as_str().starts_with(c),
StrPatternImpl::Str(s) => {
match classify(&pat) {
StrPatternKind::Char(c) => self.as_str().starts_with(c),
StrPatternKind::Str(s) => {
#[cfg(all(feature = "experimental-simd", target_arch = "x86_64"))]
{
if s.len() >= crate::simd::SIMD_THRESHOLD {
Expand Down Expand Up @@ -71,9 +71,9 @@ impl CheetahString {
/// ```
#[inline]
pub fn ends_with<P: StrPattern>(&self, pat: P) -> bool {
match pat.as_str_pattern() {
StrPatternImpl::Char(c) => self.as_str().ends_with(c),
StrPatternImpl::Str(s) => {
match classify(&pat) {
StrPatternKind::Char(c) => self.as_str().ends_with(c),
StrPatternKind::Str(s) => {
#[cfg(all(feature = "experimental-simd", target_arch = "x86_64"))]
{
if s.len() >= crate::simd::SIMD_THRESHOLD {
Expand Down Expand Up @@ -118,9 +118,9 @@ impl CheetahString {
/// ```
#[inline]
pub fn contains<P: StrPattern>(&self, pat: P) -> bool {
match pat.as_str_pattern() {
StrPatternImpl::Char(c) => self.as_str().contains(c),
StrPatternImpl::Str(s) => {
match classify(&pat) {
StrPatternKind::Char(c) => self.as_str().contains(c),
StrPatternKind::Str(s) => {
crate::search::find_bytes(self.as_bytes(), s.as_bytes()).is_some()
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
use core::fmt;
use core::str::Utf8Error;

/// Errors that can occur during `CheetahString` operations.
/// Compatibility error type for range operations and explicit conversions.
///
/// [`CheetahString::try_substring`](crate::CheetahString::try_substring) returns
/// this type through the crate's [`Result`](crate::Result) alias. UTF-8 text
/// constructors return [`Utf8Error`] directly; `Utf8Error` can still be wrapped
/// through [`From`] for source-compatible 3.1 error aggregation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// UTF-8 validation failed.
Expand Down
Loading
Loading