From da5df60bbc2f1c2a41b9d49731b0b8ff14193417 Mon Sep 17 00:00:00 2001 From: mxsm Date: Wed, 12 Aug 2026 16:51:43 +0800 Subject: [PATCH] refactor: harden 3.1 API contracts --- .github/workflows/api-compatibility.yml | 39 +++++++++++ API.md | 61 ++++++++++++++++ CHANGELOG.md | 8 ++- README.md | 10 ++- scripts/tests/test_repository_contracts.py | 14 ++++ src/cheetah_string/pattern.rs | 81 +++++++++++++++------- src/cheetah_string/query.rs | 20 +++--- src/error.rs | 7 +- tests/api_contract.rs | 75 ++++++++++++++++++++ 9 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/api-compatibility.yml create mode 100644 API.md create mode 100644 tests/api_contract.rs diff --git a/.github/workflows/api-compatibility.yml b/.github/workflows/api-compatibility.yml new file mode 100644 index 0000000..192853e --- /dev/null +++ b/.github/workflows/api-compatibility.yml @@ -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 diff --git a/API.md b/API.md new file mode 100644 index 0000000..73d3f8a --- /dev/null +++ b/API.md @@ -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` 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 +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 657df67..d977c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fed1b3e..b7f1e94 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/scripts/tests/test_repository_contracts.py b/scripts/tests/test_repository_contracts.py index 44e17bb..caee0de 100644 --- a/scripts/tests/test_repository_contracts.py +++ b/scripts/tests/test_repository_contracts.py @@ -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") diff --git a/src/cheetah_string/pattern.rs b/src/cheetah_string/pattern.rs index 3b69b99..c4b204f 100644 --- a/src/cheetah_string/pattern.rs +++ b/src/cheetah_string/pattern.rs @@ -1,51 +1,84 @@ use alloc::string::String; use core::str; +pub enum StrPatternKind<'a> { + Char(char), + Str(&'a str), +} + // 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(pattern: &P) -> StrPatternKind<'_> { + private::Sealed::classify(pattern) } /// A compatibility pattern whose iterator type exposes its capabilities. diff --git a/src/cheetah_string/query.rs b/src/cheetah_string/query.rs index 89c90e6..0a3b11e 100644 --- a/src/cheetah_string/query.rs +++ b/src/cheetah_string/query.rs @@ -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 { @@ -23,9 +23,9 @@ impl CheetahString { /// ``` #[inline] pub fn starts_with(&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 { @@ -71,9 +71,9 @@ impl CheetahString { /// ``` #[inline] pub fn ends_with(&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 { @@ -118,9 +118,9 @@ impl CheetahString { /// ``` #[inline] pub fn contains(&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() } } diff --git a/src/error.rs b/src/error.rs index 358457d..4cef05b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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. diff --git a/tests/api_contract.rs b/tests/api_contract.rs new file mode 100644 index 0000000..a89e8eb --- /dev/null +++ b/tests/api_contract.rs @@ -0,0 +1,75 @@ +use cheetah_string::{CheetahString, Error, Result as CheetahResult, StrPattern}; +use core::str::Utf8Error; + +fn assert_utf8_result(_: core::result::Result) {} + +fn assert_substring_result(_: CheetahResult) {} + +#[test] +fn pattern_compatibility_method_remains_callable_downstream() { + let character = 'x'; + let borrowed = "pattern"; + let owned = String::from("owned"); + + let _ = StrPattern::as_str_pattern(&character); + let _ = StrPattern::as_str_pattern(&borrowed); + let _ = StrPattern::as_str_pattern(&&owned); + + let value = CheetahString::from("prefix-owned-suffix"); + let prefix = String::from("prefix"); + let suffix = String::from("suffix"); + let needle = String::from("owned"); + assert!(value.starts_with(&prefix)); + assert!(value.ends_with(&suffix)); + assert!(value.contains(&needle)); +} + +#[test] +fn range_error_variants_keep_their_display_and_source_contracts() { + let cases = [ + ( + Error::IndexOutOfBounds { index: 8, len: 3 }, + "index 8 out of bounds (len: 3)", + ), + ( + Error::InvalidRange { start: 4, end: 2 }, + "range start 4 is greater than end 2", + ), + ( + Error::InvalidCharBoundary { index: 1 }, + "index 1 is not a char boundary", + ), + ]; + + for (error, display) in cases { + assert_eq!(error.to_string(), display); + #[cfg(feature = "std")] + { + use std::error::Error as _; + assert!(error.source().is_none()); + } + } +} + +#[test] +fn public_error_signatures_remain_precise_and_compatible() { + assert_utf8_result(CheetahString::try_from_bytes(b"text")); + assert_utf8_result(CheetahString::try_from_vec(b"text".to_vec())); + assert_substring_result(CheetahString::from("text").try_substring(0, 2)); + + let invalid = [0xff]; + let utf8 = core::str::from_utf8(std::hint::black_box(&invalid)) + .expect_err("input must be invalid UTF-8"); + let error = Error::from(utf8); + assert!(matches!(error, Error::Utf8Error(_))); + assert_eq!( + error.to_string(), + "UTF-8 error: invalid utf-8 sequence of 1 bytes from index 0" + ); + + #[cfg(feature = "std")] + { + use std::error::Error as _; + assert!(error.source().is_some()); + } +}