A Laravel/AdonisJS-inspired utility crate for Rust with zero-bloat, ergonomic helpers.
Version: 0.2.0
Edition: Rust 2021
MSRV: 1.92
[dependencies]
rok-utils = "0.2"use rok_utils::{to_snake_case, Str};
let snake = to_snake_case("HelloWorld");
assert_eq!(snake, "hello_world");
let result = Str::of(" Hello World ")
.trim()
.to_snake_case()
.truncate(20)
.value();| Module | Features |
|---|---|
str |
Case conversion, truncation, slug, pluralization, fluent builder |
arr |
map, filter, reduce, chunk, unique, group_by |
errors |
AdonisJS-style error codes with HTTP status |
fp |
pipe, compose, tap, retry, lazy, memoize |
data |
numbers, dates, UUIDs, hashing |
types |
JSON type guards, dot-path access |
fs |
ensure_dir, find_files, copy_dir_all |
path |
normalize, stem_ext, with_extension |
rok-utils = { version = "0.2", features = ["dates", "crypto", "ids", "json", "random"] }- Documentation Site - Modern, searchable docs
- API Reference - Rustdoc API reference
- Usage Examples
- Migration Guide
- Contributing Guide
cargo fmt --check
cargo clippy --all-features -- -D warnings
cargo test --all-features
cargo test --docMIT License - see LICENSE
- Overview & Philosophy
- Crate Configuration —
Cargo.toml - Module Structure
- String Utilities —
str.rs - Array / Collection Utilities —
arr.rs - Error Handling —
errors.rs - Data Utilities —
data.rs - Functional Patterns —
fp.rs - Fluent Builder API —
fluent.rs - Type Helpers —
types.rs - Testing Strategy
- CI/CD Pipeline
- Milestone Roadmap
rok-utils is a zero-bloat, modular utility crate for the Rok ecosystem. It follows the same
design philosophy as Laravel's Illuminate\Support\Str and AdonisJS's @adonisjs/core/helpers:
utilities are ergonomic, predictable, and composable.
- Fluent API first — chainable builder pattern mirroring Laravel's
Str::of()/ AdonisJS'sstring.camelCase().truncate()pipeline - No runtime panics — all functions return
Result<T, RokError>orOption<T>; neverunwrap()in library code - Feature-flagged — heavy dependencies (crypto, chrono, uuid) are opt-in via Cargo features
- UTF-8 native — all string operations use
chars(), never raw bytes, mirroring AdonisJS's unicode-safeescapeHTML/encodeSymbolsapproach < 400 linesper file — every module is a single focused concern; no "god files"- Fully tested — unit tests +
proptestfor invariant testing + doctests as living documentation
| Feature | Laravel (Str::) |
AdonisJS (string.) |
rok-utils |
|---|---|---|---|
| Case conversion | snake(), camel() |
snakeCase(), camelCase() |
to_snake_case() etc. |
| Fluent chaining | Str::of('x')->slug() |
N/A | Str::of("x").slug() |
| Slug generation | Str::slug() |
string.slug() |
str::slug() |
| Pluralize | Str::plural() |
string.plural() |
str::plural() (feature) |
| Error types | HttpException |
E_HTTP_EXCEPTION |
RokError enum |
| Byte/time parsing | N/A | string.bytes.parse('1MB') |
parse_bytes() etc. |
| Random string | Str::random(32) |
string.random(32) |
str::random(32) |
[package]
name = "rok-utils"
version = "0.1.0"
edition = "2021"
description = "Laravel/AdonisJS-inspired utility helpers for the Rok ecosystem"
license = "MIT"
repository = "https://github.com/ateeq1999/rok-utils"
keywords = ["utilities", "string", "helpers", "rok"]
categories = ["text-processing", "data-structures"]
# ── Core (always available) ──────────────────────────────────────────
[dependencies]
thiserror = "1.0" # Derive-based error types
once_cell = "1.19" # Lazy statics / memoization
# ── Optional features ────────────────────────────────────────────────
chrono = { version = "0.4", optional = true } # Date/time
serde = { version = "1.0", optional = true, features = ["derive"] }
serde_json = { version = "1.0", optional = true }
uuid = { version = "1.6", optional = true, features = ["v4", "v7"] }
sha2 = { version = "0.10", optional = true } # Hashing
rand = { version = "0.8", optional = true } # Random strings
unicode-segmentation = { version = "1.10", optional = true }
# ── Dev / Test ────────────────────────────────────────────────────────
[dev-dependencies]
proptest = "1.4"
criterion = { version = "0.5", features = ["html_reports"] }
# ── Feature flags ────────────────────────────────────────────────────
[features]
default = []
full = ["dates", "crypto", "json", "ids", "random", "unicode"]
dates = ["dep:chrono"]
crypto = ["dep:sha2"]
json = ["dep:serde", "dep:serde_json"]
ids = ["dep:uuid"]
random = ["dep:rand"]
unicode = ["dep:unicode-segmentation"]
[[bench]]
name = "string_bench"
harness = false// Consumer opts in to only what they need:
// rok-utils = { version = "0.1", features = ["dates", "crypto"] }
#[cfg(feature = "dates")]
pub use crate::data::dates::*;
#[cfg(feature = "crypto")]
pub use crate::data::hashing::*;rok-utils/
├── Cargo.toml
├── src/
│ ├── lib.rs ← Public re-exports, feature gates
│ ├── str/
│ │ ├── mod.rs ← str::* top-level re-exports
│ │ ├── case.rs ← Case conversions (snake, camel, pascal…)
│ │ ├── transform.rs ← slug, truncate, excerpt, squish, wrap…
│ │ ├── inspect.rs ← is_empty, starts_with, contains, word_count…
│ │ ├── fluent.rs ← Str::of() builder (chainable API)
│ │ └── random.rs ← random() — requires "random" feature
│ ├── arr/
│ │ ├── mod.rs
│ │ ├── ops.rs ← map, filter, reduce, chunk, flatten…
│ │ ├── query.rs ← first, last, find, where_in, pluck…
│ │ └── set.rs ← unique, diff, intersect, merge…
│ ├── errors/
│ │ ├── mod.rs
│ │ ├── kinds.rs ← RokError enum (all variants)
│ │ ├── context.rs ← wrap_error, add_context, ResultExt trait
│ │ └── http.rs ← HttpError with status codes (AdonisJS-style)
│ ├── data/
│ │ ├── mod.rs
│ │ ├── numbers.rs ← format_number, format_currency, round…
│ │ ├── dates.rs ← now, today, format, diff (needs "dates")
│ │ ├── hashing.rs ← hash, verify, generate_token (needs "crypto")
│ │ └── ids.rs ← uuid_v4, uuid_v7, ulid (needs "ids")
│ ├── fp/
│ │ ├── mod.rs
│ │ ├── compose.rs ← pipe, compose, partial, tap
│ │ ├── lazy.rs ← Lazy<T>, memoize, once
│ │ └── macros.rs ← macro_rules! helpers
│ └── types/
│ ├── mod.rs
│ └── guards.rs ← is_json, is_uuid, is_ulid, is_url…
├── tests/
│ ├── str_tests.rs
│ ├── arr_tests.rs
│ ├── error_tests.rs
│ └── proptest_suite.rs
└── benches/
└── string_bench.rs
Modeled after Laravel's Illuminate\Support\Str and AdonisJS's string helper module.
All functions are free functions in rok_utils::str and also available on the fluent Str builder.
Mirrors AdonisJS camelCase, snakeCase, pascalCase, dashCase, dotCase, noCase, sentenceCase, titleCase, and Laravel Str::camel(), Str::snake(), Str::studly(), Str::title(), Str::headline().
pub fn to_camel_case(s: &str) -> String
pub fn to_snake_case(s: &str) -> String
pub fn to_pascal_case(s: &str) -> String // "studly" in Laravel
pub fn to_kebab_case(s: &str) -> String // "dash" in AdonisJS
pub fn to_dot_case(s: &str) -> String // AdonisJS dotCase
pub fn to_title_case(s: &str) -> String
pub fn to_headline(s: &str) -> String // Laravel Str::headline()
pub fn to_sentence_case(s: &str) -> String // AdonisJS sentenceCase
pub fn to_no_case(s: &str) -> String // AdonisJS noCase — strips all casing
pub fn to_upper(s: &str) -> String
pub fn to_lower(s: &str) -> String
pub fn ucfirst(s: &str) -> String // Laravel Str::ucfirst()
pub fn lcfirst(s: &str) -> String // Laravel Str::lcfirst()
pub fn invert_case(s: &str) -> String// to_snake_case: handles "TestV2" → "test_v2", "XMLParser" → "xml_parser"
// Uses char-by-char state machine, never regex, to stay no_std-compatible.
pub fn to_snake_case(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut prev_upper = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c.is_uppercase() {
let next_lower = chars.peek().map(|n| n.is_lowercase()).unwrap_or(false);
if !out.is_empty() && (!prev_upper || next_lower) {
out.push('_');
}
out.extend(c.to_lowercase());
prev_upper = true;
} else if c == '-' || c == ' ' {
out.push('_');
prev_upper = false;
} else {
out.push(c);
prev_upper = false;
}
}
out
}Case Conversion Examples (AdonisJS reference table, adapted for Rust):
| Input | snake |
camel |
pascal |
kebab |
|---|---|---|---|---|
"test string" |
test_string |
testString |
TestString |
test-string |
"TestV2" |
test_v2 |
testV2 |
TestV2 |
test-v2 |
"XMLParser" |
xml_parser |
xmlParser |
XmlParser |
xml-parser |
"version 1.2" |
version_1_2 |
version12 |
Version12 |
version-12 |
/// Laravel: Str::slug() / AdonisJS: string.slug()
pub fn slug(s: &str, separator: char) -> String
/// Laravel: Str::limit() / AdonisJS: string.truncate()
/// Optionally complete the last word before cutting (AdonisJS `completeWords` option)
pub fn truncate(s: &str, limit: usize, complete_words: bool, suffix: &str) -> String
/// AdonisJS: string.excerpt() — like truncate but strips HTML tags first
pub fn excerpt(s: &str, limit: usize, complete_words: bool) -> String
/// Laravel: Str::squish() / AdonisJS: string.condenseWhitespace()
/// Collapses all internal whitespace runs to a single space, trims edges
pub fn squish(s: &str) -> String
/// Laravel: Str::mask() — masks characters with a given char
/// e.g. mask("hello@world.com", '*', 5) → "hello@*******"
pub fn mask(s: &str, mask_char: char, index: usize) -> String
/// Laravel: Str::wrap() / Str::unwrap()
pub fn wrap(s: &str, before: &str, after: &str) -> String
pub fn unwrap(s: &str, before: &str, after: &str) -> String
/// Laravel: Str::padLeft() / Str::padRight() / Str::padBoth()
pub fn pad_left(s: &str, length: usize, pad: char) -> String
pub fn pad_right(s: &str, length: usize, pad: char) -> String
pub fn pad_both(s: &str, length: usize, pad: char) -> String
/// Laravel: Str::repeat()
pub fn repeat(s: &str, times: usize) -> String
/// Laravel: Str::reverse()
pub fn reverse(s: &str) -> String
/// Laravel: Str::replaceFirst() / Str::replaceLast()
pub fn replace_first(s: &str, from: &str, to: &str) -> String
pub fn replace_last(s: &str, from: &str, to: &str) -> String
/// Laravel: Str::finish() — ensures string ends with value
pub fn finish(s: &str, cap: &str) -> String
/// Laravel: Str::start() — ensures string starts with value
pub fn ensure_start(s: &str, prefix: &str) -> String
/// Laravel: Str::chopStart() / Str::chopEnd()
pub fn chop_start(s: &str, remove: &str) -> &str
pub fn chop_end(s: &str, remove: &str) -> &str
/// Laravel: Str::after() / Str::afterLast()
pub fn after(s: &str, needle: &str) -> &str
pub fn after_last(s: &str, needle: &str) -> &str
/// Laravel: Str::before() / Str::beforeLast()
pub fn before(s: &str, needle: &str) -> &str
pub fn before_last(s: &str, needle: &str) -> &str
/// Laravel: Str::between() / Str::betweenFirst()
pub fn between(s: &str, start: &str, end: &str) -> String
pub fn between_first(s: &str, start: &str, end: &str) -> String
/// AdonisJS: string.interpolate() — "hello {{ name }}" + map
pub fn interpolate(template: &str, vars: &std::collections::HashMap<&str, &str>) -> String
/// AdonisJS: string.sentence() — ["a","b","c"] → "a, b, and c"
pub fn to_sentence(words: &[&str], last_separator: &str) -> String
/// Laravel: Str::wordWrap()
pub fn word_wrap(s: &str, width: usize, break_with: &str) -> String
/// Laravel: Str::words() — truncate at word count
pub fn words(s: &str, count: usize, end: &str) -> String
/// Laravel: Str::swap() — multiple simultaneous replacements
pub fn swap(s: &str, replacements: &std::collections::HashMap<&str, &str>) -> String
/// Laravel: Str::deduplicate() — collapses repeated chars
pub fn deduplicate(s: &str, character: char) -> String
/// Laravel: Str::toBase64() / Str::fromBase64()
pub fn to_base64(s: &str) -> String
pub fn from_base64(s: &str) -> Result<String, RokError>
/// HTML escape — AdonisJS: string.escapeHTML()
pub fn escape_html(s: &str) -> String
/// AdonisJS: string.encodeSymbols()
pub fn encode_symbols(s: &str) -> String
/// AdonisJS: string.bytes.parse / string.seconds.parse / string.milliseconds.parse
pub fn parse_bytes(expr: &str) -> Result<u64, RokError>
pub fn parse_seconds(expr: &str) -> Result<u64, RokError>
pub fn parse_milliseconds(expr: &str) -> Result<u64, RokError>pub fn is_empty(s: &str) -> bool // trims first — AdonisJS: string.isEmpty()
pub fn is_ascii(s: &str) -> bool // Laravel: Str::isAscii()
pub fn is_json(s: &str) -> bool // Laravel: Str::isJson()
pub fn is_url(s: &str) -> bool // Laravel: Str::isUrl()
pub fn is_uuid(s: &str) -> bool // Laravel: Str::isUuid()
pub fn is_ulid(s: &str) -> bool // Laravel: Str::isUlid()
pub fn is_alphanumeric(s: &str) -> bool
pub fn is_match(s: &str, pattern: &str) -> bool // Laravel: Str::isMatch()
pub fn length(s: &str) -> usize // char count, not byte count
pub fn word_count(s: &str) -> usize // Laravel: Str::wordCount()
pub fn char_at(s: &str, index: usize) -> Option<char> // Laravel: Str::charAt()
pub fn position(s: &str, needle: &str) -> Option<usize> // Laravel: Str::position()
pub fn substr_count(s: &str, needle: &str) -> usize
pub fn starts_with(s: &str, needle: &str) -> bool
pub fn ends_with(s: &str, needle: &str) -> bool
pub fn contains(s: &str, needle: &str) -> bool
pub fn contains_all(s: &str, needles: &[&str]) -> bool
pub fn doesnt_contain(s: &str, needle: &str) -> bool
/// AdonisJS: string.prettyHrTime() equivalent
pub fn pretty_duration(nanos: u64) -> String/// Cryptographically secure random string, URL-safe base64
/// Laravel: Str::random() / AdonisJS: string.random()
///
/// string::random(32) → "8mejfWWbXbry8Rh7u8MW3o-6dxd80Thk"
pub fn random(length: usize) -> String
/// Generate a secure password (letters + digits + symbols)
/// Laravel: Str::password()
pub fn password(length: usize, symbols: bool) -> String/// Laravel: Str::plural() / Str::singular()
/// AdonisJS: string.plural(), string.singular(), string.pluralize()
pub fn plural(word: &str, count: usize) -> String
pub fn singular(word: &str) -> String
pub fn pluralize(word: &str, count: usize) -> String // picks singular or plural based on count
pub fn is_plural(word: &str) -> bool
pub fn is_singular(word: &str) -> boolMirrors Laravel's Str::of() — every method returns Self for chaining.
Unlike Laravel's PHP fluent strings, this uses Rust's move semantics and zero-copy slices where possible.
pub struct Str {
inner: String,
}
impl Str {
/// Entry point: Str::of("hello world")
pub fn of(s: impl Into<String>) -> Self
// ── Transform (mutating, returns Self) ──────────────────────────
pub fn slug(self) -> Self
pub fn snake(self) -> Self
pub fn camel(self) -> Self
pub fn pascal(self) -> Self
pub fn kebab(self) -> Self
pub fn title(self) -> Self
pub fn upper(self) -> Self
pub fn lower(self) -> Self
pub fn trim(self) -> Self
pub fn ltrim(self) -> Self
pub fn rtrim(self) -> Self
pub fn squish(self) -> Self
pub fn truncate(self, limit: usize) -> Self
pub fn truncate_words(self, limit: usize) -> Self
pub fn reverse(self) -> Self
pub fn repeat(self, times: usize) -> Self
pub fn append(self, s: &str) -> Self
pub fn prepend(self, s: &str) -> Self
pub fn replace(self, from: &str, to: &str) -> Self
pub fn replace_first(self, from: &str, to: &str) -> Self
pub fn replace_last(self, from: &str, to: &str) -> Self
pub fn finish(self, cap: &str) -> Self
pub fn ensure_start(self, prefix: &str) -> Self
pub fn wrap(self, before: &str, after: &str) -> Self
pub fn pad_left(self, n: usize) -> Self
pub fn pad_right(self, n: usize) -> Self
pub fn pad_both(self, n: usize) -> Self
pub fn mask(self, mask_char: char, from: usize) -> Self
pub fn escape_html(self) -> Self
// ── Conditional (Laravel whenX / whenEmpty) ─────────────────────
pub fn when(self, condition: bool, f: impl FnOnce(Self) -> Self) -> Self
pub fn when_empty(self, f: impl FnOnce(Self) -> Self) -> Self
pub fn when_not_empty(self, f: impl FnOnce(Self) -> Self) -> Self
pub fn when_contains(self, needle: &str, f: impl FnOnce(Self) -> Self) -> Self
pub fn when_starts_with(self, prefix: &str, f: impl FnOnce(Self) -> Self) -> Self
pub fn when_ends_with(self, suffix: &str, f: impl FnOnce(Self) -> Self) -> Self
// ── Tap (side-effect, Laravel: tap()) ────────────────────────────
pub fn tap(self, f: impl FnOnce(&str)) -> Self
// ── Pipe (transform with arbitrary closure) ───────────────────────
pub fn pipe<F: FnOnce(String) -> String>(self, f: F) -> Self
// ── Terminal (consumes Self, returns concrete value) ─────────────
pub fn to_string(self) -> String
pub fn len(&self) -> usize
pub fn is_empty(&self) -> bool
pub fn contains(&self, needle: &str) -> bool
pub fn starts_with(&self, prefix: &str) -> bool
pub fn ends_with(&self, suffix: &str) -> bool
pub fn word_count(&self) -> usize
pub fn to_base64(self) -> String
pub fn split(self, delimiter: &str) -> Vec<String>
pub fn matches(&self, pattern: &str) -> bool
pub fn match_all(&self, pattern: &str) -> Vec<String>
pub fn exactly(&self, other: &str) -> bool
pub fn value(self) -> String // alias for to_string()
}Usage:
use rok_utils::str::Str;
let result = Str::of(" Hello World ")
.trim()
.to_snake_case()
.slug()
.truncate(20)
.when_empty(|s| s.append("default"))
.value();
// "hello-world"
let banner = Str::of("welcome to rok")
.title()
.wrap("=== ", " ===")
.value();
// "=== Welcome To Rok ==="Inspired by Laravel's Collection API and AdonisJS array helpers. All functions are generic
over T and use Rust's Iterator trait internally.
/// Map over a slice, collecting into Vec<U>
pub fn map<T, U>(arr: &[T], f: impl Fn(&T) -> U) -> Vec<U>
/// Filter with predicate
pub fn filter<T: Clone>(arr: &[T], f: impl Fn(&T) -> bool) -> Vec<T>
/// Filter-map (map + unwrap Some values)
pub fn filter_map<T, U>(arr: &[T], f: impl Fn(&T) -> Option<U>) -> Vec<U>
/// Reduce with accumulator
pub fn reduce<T, A>(arr: &[T], init: A, f: impl Fn(A, &T) -> A) -> A
/// Split into fixed-size chunks — Laravel: chunk()
pub fn chunk<T: Clone>(arr: &[T], size: usize) -> Vec<Vec<T>>
/// Flatten one level
pub fn flatten<T: Clone>(arr: &[Vec<T>]) -> Vec<T>
/// Flatten all levels (recursive via trait)
pub fn flatten_deep<T: Clone>(arr: &[serde_json::Value]) -> Vec<serde_json::Value>
/// Remove falsy/empty values — Laravel: compact()
pub fn compact<T: Default + PartialEq + Clone>(arr: &[T]) -> Vec<T>
/// Take first N items
pub fn take<T: Clone>(arr: &[T], n: usize) -> Vec<T>
/// Drop first N items
pub fn skip<T: Clone>(arr: &[T], n: usize) -> Vec<T>
/// Reverse
pub fn reverse<T: Clone>(arr: &[T]) -> Vec<T>
/// Zip two slices together
pub fn zip<A: Clone, B: Clone>(a: &[A], b: &[B]) -> Vec<(A, B)>pub fn first<T>(arr: &[T]) -> Option<&T>
pub fn last<T>(arr: &[T]) -> Option<&T>
pub fn get<T>(arr: &[T], index: usize) -> Option<&T>
/// Find first matching element
pub fn find<T>(arr: &[T], f: impl Fn(&T) -> bool) -> Option<&T>
/// Any element matches
pub fn some<T>(arr: &[T], f: impl Fn(&T) -> bool) -> bool
/// All elements match
pub fn every<T>(arr: &[T], f: impl Fn(&T) -> bool) -> bool
/// Check membership
pub fn contains<T: PartialEq>(arr: &[T], value: &T) -> bool
/// Group elements by key — Laravel: groupBy()
pub fn group_by<T, K>(arr: &[T], key_fn: impl Fn(&T) -> K) -> HashMap<K, Vec<T>>
where K: Eq + Hash, T: Clone
/// Index by unique key — Laravel: keyBy()
pub fn key_by<T, K>(arr: &[T], key_fn: impl Fn(&T) -> K) -> HashMap<K, T>
where K: Eq + Hash, T: Clone
/// Pluck a field from each item — Laravel: pluck()
pub fn pluck<T, U>(arr: &[T], extractor: impl Fn(&T) -> U) -> Vec<U>
/// where_in style filter
pub fn where_in<T: PartialEq + Clone>(arr: &[T], values: &[T]) -> Vec<T>
pub fn count<T>(arr: &[T]) -> usize
pub fn is_empty<T>(arr: &[T]) -> bool
pub fn is_not_empty<T>(arr: &[T]) -> bool/// Remove duplicates (preserves order) — Laravel/AdonisJS: unique()
pub fn unique<T: PartialEq + Clone>(arr: &[T]) -> Vec<T>
/// Remove specific values — Laravel: without()
pub fn without<T: PartialEq + Clone>(arr: &[T], values: &[T]) -> Vec<T>
/// Merge two slices (no dedup)
pub fn merge<T: Clone>(a: &[T], b: &[T]) -> Vec<T>
/// Set intersection
pub fn intersect<T: PartialEq + Clone>(a: &[T], b: &[T]) -> Vec<T>
/// Set difference (a - b)
pub fn diff<T: PartialEq + Clone>(a: &[T], b: &[T]) -> Vec<T>
/// Sort with comparator
pub fn sort_by<T: Clone>(arr: &[T], cmp: impl Fn(&T, &T) -> std::cmp::Ordering) -> Vec<T>
/// Shuffle (requires feature = "random")
#[cfg(feature = "random")]
pub fn shuffle<T: Clone>(arr: &[T]) -> Vec<T>Inspired by AdonisJS's typed exception system (E_ROUTE_NOT_FOUND, E_UNAUTHORIZED_ACCESS,
E_HTTP_EXCEPTION, etc.) and Laravel's HttpException. Every error variant carries a
machine-readable code string matching the AdonisJS convention.
use thiserror::Error;
/// All rok-utils errors. Each variant maps to an AdonisJS-style error code.
#[derive(Debug, Error)]
pub enum RokError {
// ── String errors ──────────────────────────────────────────────
#[error("[E_INVALID_UTF8] Invalid UTF-8 input: {0}")]
InvalidUtf8(String),
#[error("[E_INVALID_BASE64] Cannot decode base64 string: {0}")]
InvalidBase64(String),
// ── Parse errors ───────────────────────────────────────────────
#[error("[E_PARSE_BYTES] Cannot parse byte expression '{expr}': {reason}")]
ParseBytes { expr: String, reason: String },
#[error("[E_PARSE_DURATION] Cannot parse duration expression '{expr}': {reason}")]
ParseDuration { expr: String, reason: String },
// ── Data errors ────────────────────────────────────────────────
#[error("[E_INVALID_JSON] JSON parse failed: {0}")]
InvalidJson(String),
#[error("[E_INVALID_UUID] '{0}' is not a valid UUID")]
InvalidUuid(String),
#[error("[E_INVALID_DATE] Cannot parse date '{0}'")]
InvalidDate(String),
// ── HTTP-style errors (AdonisJS parity) ────────────────────────
#[error("[E_NOT_FOUND] Resource not found: {0}")]
NotFound(String), // HTTP 404
#[error("[E_UNAUTHORIZED] Unauthorized: {0}")]
Unauthorized(String), // HTTP 401
#[error("[E_FORBIDDEN] Forbidden: {0}")]
Forbidden(String), // HTTP 403
#[error("[E_VALIDATION_FAILURE] Validation failed: {field} — {reason}")]
ValidationFailure { field: String, reason: String }, // HTTP 422
#[error("[E_TOO_MANY_REQUESTS] Rate limit exceeded")]
TooManyRequests, // HTTP 429
#[error("[E_INTERNAL] Internal error: {0}")]
Internal(String), // HTTP 500
// ── Generic wrapped error ──────────────────────────────────────
#[error("[E_WRAPPED] {message}: {source}")]
Wrapped {
message: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
impl RokError {
/// AdonisJS-style error code string
pub fn code(&self) -> &'static str {
match self {
Self::InvalidUtf8(_) => "E_INVALID_UTF8",
Self::InvalidBase64(_) => "E_INVALID_BASE64",
Self::ParseBytes { .. } => "E_PARSE_BYTES",
Self::ParseDuration{..} => "E_PARSE_DURATION",
Self::InvalidJson(_) => "E_INVALID_JSON",
Self::InvalidUuid(_) => "E_INVALID_UUID",
Self::InvalidDate(_) => "E_INVALID_DATE",
Self::NotFound(_) => "E_NOT_FOUND",
Self::Unauthorized(_) => "E_UNAUTHORIZED",
Self::Forbidden(_) => "E_FORBIDDEN",
Self::ValidationFailure{..} => "E_VALIDATION_FAILURE",
Self::TooManyRequests => "E_TOO_MANY_REQUESTS",
Self::Internal(_) => "E_INTERNAL",
Self::Wrapped { .. } => "E_WRAPPED",
}
}
/// HTTP status code — compatible with AdonisJS status semantics
pub fn status(&self) -> u16 {
match self {
Self::NotFound(_) => 404,
Self::Unauthorized(_) => 401,
Self::Forbidden(_) => 403,
Self::ValidationFailure{..} => 422,
Self::TooManyRequests => 429,
_ => 500,
}
}
/// Whether this error is recoverable / self-handled
/// AdonisJS: "self-handled" exceptions convert themselves to HTTP responses
pub fn is_self_handled(&self) -> bool {
matches!(self,
Self::NotFound(_)
| Self::Unauthorized(_)
| Self::Forbidden(_)
| Self::ValidationFailure { .. }
| Self::TooManyRequests
)
}
}/// Add context to any error (AdonisJS: error.help / error.cause pattern)
pub fn wrap_error<E>(error: E, message: impl Into<String>) -> RokError
where E: std::error::Error + Send + Sync + 'static
/// Extension trait on Result — Laravel-style ergonomics
pub trait ResultExt<T> {
fn context(self, msg: &str) -> Result<T, RokError>;
fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T, RokError>;
fn map_rok_err(self, variant: fn(String) -> RokError) -> Result<T, RokError>;
fn or_not_found(self, resource: &str) -> Result<T, RokError>;
}
impl<T, E: std::error::Error + Send + Sync + 'static> ResultExt<T> for Result<T, E> {
fn context(self, msg: &str) -> Result<T, RokError> {
self.map_err(|e| wrap_error(e, msg))
}
fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T, RokError> {
self.map_err(|e| wrap_error(e, f()))
}
fn map_rok_err(self, variant: fn(String) -> RokError) -> Result<T, RokError> {
self.map_err(|e| variant(e.to_string()))
}
fn or_not_found(self, resource: &str) -> Result<T, RokError> {
self.map_err(|_| RokError::NotFound(resource.to_string()))
}
}Usage:
use rok_utils::errors::{RokError, ResultExt};
fn find_user(id: u64) -> Result<User, RokError> {
db::find(id)
.context("Failed to query user table")
.or_not_found(&format!("User #{id}"))
}
// Error codes match AdonisJS convention:
let err = RokError::NotFound("User #42".into());
assert_eq!(err.code(), "E_NOT_FOUND");
assert_eq!(err.status(), 404);
assert!(err.is_self_handled());/// Quickly build a validation error
macro_rules! validation_err {
($field:expr, $reason:expr) => {
RokError::ValidationFailure {
field: $field.to_string(),
reason: $reason.to_string(),
}
};
}
/// Bail early from a function with a RokError
macro_rules! rok_bail {
($variant:ident, $msg:expr) => {
return Err(RokError::$variant($msg.to_string()))
};
}/// Format with thousand separators: 1_234_567.89 → "1,234,567.89"
pub fn format_number(n: f64, decimals: usize, sep: char) -> String
/// Format as currency: format_currency(1234.5, "USD") → "$1,234.50"
pub fn format_currency(amount: f64, currency: &str) -> String
/// Format as percentage: 0.1234 → "12.34%"
pub fn format_percentage(ratio: f64, decimals: usize) -> String
pub fn round(n: f64, decimals: u32) -> f64
pub fn ceil(n: f64, decimals: u32) -> f64
pub fn floor(n: f64, decimals: u32) -> f64
pub fn clamp(n: f64, min: f64, max: f64) -> f64
pub fn lerp(a: f64, b: f64, t: f64) -> f64use chrono::{DateTime, Utc, NaiveDate, Duration};
pub fn now() -> DateTime<Utc>
pub fn today() -> NaiveDate
pub fn yesterday() -> NaiveDate
pub fn tomorrow() -> NaiveDate
/// Format date: format_date(now(), "%Y-%m-%d") → "2025-01-15"
pub fn format_date(dt: &DateTime<Utc>, fmt: &str) -> String
/// Parse from string: parse_date("2025-01-15", "%Y-%m-%d")
pub fn parse_date(s: &str, fmt: &str) -> Result<DateTime<Utc>, RokError>
/// Diff in days between two dates
pub fn diff_days(a: &NaiveDate, b: &NaiveDate) -> i64
/// Human-readable relative time: "3 days ago", "in 5 minutes"
pub fn human_diff(dt: &DateTime<Utc>) -> String
/// Add duration
pub fn add_days(dt: &NaiveDate, days: i64) -> NaiveDate
pub fn add_hours(dt: &DateTime<Utc>, hours: i64) -> DateTime<Utc>
/// AdonisJS: string.seconds.parse("10h") → 36000
/// (non-feature gated, implemented in str/transform.rs as parse_seconds)
/// UUID v4 (random) — Laravel: Str::uuid()
pub fn uuid_v4() -> String
/// UUID v7 (time-ordered) — Laravel: Str::uuid7() / Str::orderedUuid()
pub fn uuid_v7() -> String
/// ULID — Laravel: Str::ulid()
pub fn ulid() -> String
/// Validate UUID string — Laravel: Str::isUuid()
pub fn is_uuid(s: &str) -> bool
/// Validate ULID string — Laravel: Str::isUlid()
pub fn is_ulid(s: &str) -> bool/// SHA-256 hex digest
pub fn hash_sha256(input: &str) -> String
/// Verify SHA-256 hash
pub fn verify_sha256(input: &str, expected: &str) -> bool
/// Generate a secure opaque token (URL-safe base64 of random bytes)
/// Like AdonisJS opaque tokens
pub fn generate_token(bytes: usize) -> String
/// Constant-time comparison to prevent timing attacks
pub fn secure_compare(a: &str, b: &str) -> boolInspired by Laravel's pipeline, AdonisJS middleware chaining, and standard functional programming utilities.
/// Thread a value through a sequence of transformations
/// Mirrors Laravel's pipeline / AdonisJS middleware chain
pub fn pipe<T>(value: T, fns: Vec<Box<dyn Fn(T) -> T>>) -> T
/// Compose two functions: compose(f, g)(x) = f(g(x))
pub fn compose<A, B, C>(f: impl Fn(B) -> C, g: impl Fn(A) -> B) -> impl Fn(A) -> C
/// Apply a side-effect then return the original value — Laravel: tap()
pub fn tap<T>(value: T, f: impl FnOnce(&T)) -> T
/// Apply function and return result — AdonisJS: apply
pub fn apply<T, U>(value: T, f: impl FnOnce(T) -> U) -> U
/// Return default if None — convenience over Option::unwrap_or_default
pub fn or_default<T: Default>(opt: Option<T>) -> T
/// Retry an operation N times before failing
pub fn retry<T, E>(times: usize, f: impl Fn() -> Result<T, E>) -> Result<T, E>use once_cell::sync::OnceCell;
/// Lazily initialized value — computed only on first access
pub struct Lazy<T> {
cell: OnceCell<T>,
init: Box<dyn Fn() -> T + Send + Sync>,
}
impl<T> Lazy<T> {
pub fn new(init: impl Fn() -> T + Send + Sync + 'static) -> Self
pub fn get(&self) -> &T // computes on first call, returns cached after
pub fn is_initialized(&self) -> bool
}
/// Memoize a single-argument function using a HashMap cache
pub fn memoize<A, R>(f: impl Fn(A) -> R) -> impl FnMut(A) -> R
where
A: std::hash::Hash + Eq + Clone,
R: Clone,
/// Run a closure exactly once, cache the result (thread-safe)
/// Wraps `once_cell::sync::OnceCell`
pub fn once<T>(f: impl FnOnce() -> T) -> impl Fn() -> T/// Build a Vec of boxed closures for use with pipe()
macro_rules! pipeline {
($($fn:expr),* $(,)?) => {
vec![$(Box::new($fn) as Box<dyn Fn(_) -> _>),*]
};
}
/// Unwrap or return Err with a RokError variant
macro_rules! require {
($opt:expr, $err:expr) => {
match $opt {
Some(v) => v,
None => return Err($err),
}
};
}
/// Conditional expression returning a value
macro_rules! when {
($cond:expr => $then:expr, else $else:expr) => {
if $cond { $then } else { $else }
};
}The top-level Str builder (see §4.6) is the primary ergonomic API, but rok-utils also provides
collection-level fluency via Collect<T>:
pub struct Collect<T> {
inner: Vec<T>,
}
impl<T: Clone + 'static> Collect<T> {
pub fn of(arr: Vec<T>) -> Self
pub fn map<U>(self, f: impl Fn(T) -> U) -> Collect<U>
pub fn filter(self, f: impl Fn(&T) -> bool) -> Self
pub fn take(self, n: usize) -> Self
pub fn skip(self, n: usize) -> Self
pub fn unique(self) -> Self where T: PartialEq
pub fn chunk(self, size: usize) -> Collect<Vec<T>>
pub fn tap(self, f: impl FnOnce(&[T])) -> Self
pub fn when(self, cond: bool, f: impl FnOnce(Self) -> Self) -> Self
pub fn sort_by(self, cmp: impl Fn(&T, &T) -> std::cmp::Ordering) -> Self
pub fn value(self) -> Vec<T>
pub fn first(self) -> Option<T>
pub fn last(self) -> Option<T>
pub fn count(&self) -> usize
pub fn is_empty(&self) -> bool
}AdonisJS ships @adonisjs/core/helpers/is for runtime type guards. We mirror this:
pub fn is_string(val: &serde_json::Value) -> bool
pub fn is_number(val: &serde_json::Value) -> bool
pub fn is_bool(val: &serde_json::Value) -> bool
pub fn is_array(val: &serde_json::Value) -> bool
pub fn is_object(val: &serde_json::Value) -> bool
pub fn is_null(val: &serde_json::Value) -> bool
pub fn is_defined(val: &serde_json::Value) -> bool // not null/undefined
/// Deep equality (recursive)
pub fn deep_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool
/// Get nested value by dot path: get_path(&json, "user.address.city")
pub fn get_path<'a>(val: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value>
/// Set nested value by dot path (returns modified clone)
pub fn set_path(val: serde_json::Value, path: &str, new: serde_json::Value) -> serde_json::ValueEvery public function has at least:
- A happy-path test
- An edge case (empty string, zero, negative numbers, Unicode input)
- A
#[should_panic]orErrassertion where appropriate
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_snake_case_basic() {
assert_eq!(to_snake_case("HelloWorld"), "hello_world");
assert_eq!(to_snake_case("XMLParser"), "xml_parser");
assert_eq!(to_snake_case("TestV2"), "test_v2");
assert_eq!(to_snake_case(""), "");
}
#[test]
fn test_truncate_complete_words() {
let s = "This is a very long title";
assert_eq!(truncate(s, 10, true, "..."), "This is a...");
assert_eq!(truncate(s, 10, false, "..."), "This is a ...");
}
#[test]
fn test_slug_unicode() {
assert_eq!(slug("hello ♥ world", '-'), "hello-love-world");
}
}use proptest::prelude::*;
proptest! {
/// round-trip: snake → camel → snake should be stable
#[test]
fn prop_snake_camel_roundtrip(s in "[a-z][a-z0-9]{0,20}(_[a-z][a-z0-9]{0,10})*") {
let result = to_snake_case(&to_camel_case(&s));
prop_assert_eq!(result, s);
}
/// truncate never returns more chars than the limit
#[test]
fn prop_truncate_length(s in ".*", limit in 0usize..200) {
let result = truncate(&s, limit, false, "");
prop_assert!(result.chars().count() <= limit + 3); // +3 for suffix
}
/// unique preserves all elements that appeared at least once
#[test]
fn prop_unique_subset(arr in prop::collection::vec(0i32..100, 0..50)) {
let result = unique(&arr);
for x in &result {
prop_assert!(arr.contains(x));
}
}
}// benches/string_bench.rs
use criterion::{criterion_group, criterion_main, Criterion};
use rok_utils::str::*;
fn bench_slug(c: &mut Criterion) {
let input = "The Quick Brown Fox Jumps Over The Lazy Dog";
c.bench_function("slug", |b| b.iter(|| slug(input, '-')));
}
fn bench_fluent_chain(c: &mut Criterion) {
c.bench_function("fluent_chain", |b| {
b.iter(|| {
Str::of(" Hello World ")
.trim()
.slug()
.truncate(30)
.value()
})
});
}
criterion_group!(benches, bench_slug, bench_fluent_chain);
criterion_main!(benches);Every public function has a /// # Examples block that doubles as a doctest:
/// Convert a string to snake_case.
///
/// # Examples
///
/// ```rust
/// use rok_utils::str::to_snake_case;
///
/// assert_eq!(to_snake_case("HelloWorld"), "hello_world");
/// assert_eq!(to_snake_case("XMLParser"), "xml_parser");
/// assert_eq!(to_snake_case(""), "");
/// ```
pub fn to_snake_case(s: &str) -> String { ... }# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (no warnings allowed)
run: cargo clippy --all-features -- -D warnings
- name: Tests (all features)
run: cargo test --all-features
- name: Tests (no default features)
run: cargo test --no-default-features
- name: Doctests
run: cargo test --doc --all-features
- name: Proptest suite
run: cargo test --test proptest_suite --all-features
bench:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo bench --all-features
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-tarpaulin
- run: cargo tarpaulin --all-features --out Xml
- uses: codecov/codecov-action@v3Goal: Core string, array, and error modules are complete and published to crates.io.
-
str/case.rs— all 12 case conversion functions with doctests -
str/transform.rs— 25+ transform functions (slug, truncate, excerpt, squish, wrap…) -
str/inspect.rs— 15+ inspection functions -
str/fluent.rs—Str::of()builder with 30+ chainable methods andwhen*conditionals -
arr/ops.rs,arr/query.rs,arr/set.rs— full collection API -
errors/kinds.rs—RokErrorenum with AdonisJS-style codes + HTTP status -
errors/context.rs—ResultExttrait andwrap_error -
fp/compose.rs—pipe,tap,compose,retry - CI green: fmt + clippy + tests + doctests
- Code coverage ≥ 90%
- All files ≤ 400 lines
Goal: Optional modules enabled via feature flags are ready for production.
-
data/numbers.rs—format_number,format_currency,format_percentage -
data/dates.rs— chrono-backed date helpers (feature = "dates") -
data/ids.rs— UUID v4/v7, ULID (feature = "ids") -
data/hashing.rs— SHA-256, tokens (feature = "crypto") -
str/random.rs— cryptographically securerandom()andpassword()(feature = "random") -
str/plural.rs— pluralize, singular, is_plural (feature = "unicode") -
fp/lazy.rs—Lazy<T>,memoize,once -
types/guards.rs— JSON type guards and dot-path access
Goal: Replace all ad-hoc string handling in rok-cli with rok-utils.
- Replace internal case conversion with
rok_utils::str::to_snake_caseetc. - Use
Str::of()fluent builder in code generation templates - Wire
RokErrorinto CLI error reporting pipeline -
rok generate model <Name>usesto_pascal_case+to_snake_caseconsistently - Benchmark: no regression vs. previous hand-rolled string code
Goal: rok-orm macro system uses rok-utils for identifier generation.
-
model!macro usesto_snake_casefor table names -
migrate!usesuuid_v7for migration timestamps -
query!builder usesCollect<T>for result mapping - Error propagation: database errors wrap to
RokError::Internal
Goal: Public API is frozen and fully documented.
-
cargo doc --all-features --no-depsgenerates complete API reference - Deploy to GitHub Pages via CI
- Add
#[non_exhaustive]toRokErrorenum - Write migration guide from
0.xto1.0 - All public types implement
Debug + Clone + Send + Syncwhere applicable - Minimum Supported Rust Version (MSRV) pinned to stable - 2 releases
This plan delivers a rok-utils crate that:
- Mirrors battle-tested APIs — Laravel's
Strand AdonisJS'sstringhelpers are the benchmark; every function name and behavior maps to a known analogue - Ergonomic by default — the fluent
Str::of()builder makes complex transformations readable and composable without intermediate variables - Error-first design — AdonisJS-style typed error codes (
E_NOT_FOUND,E_VALIDATION_FAILURE) with HTTP status semantics baked in from day one - Zero-bloat — feature flags ensure consumers pay only for what they use
- Production-ready — proptest invariants, criterion benchmarks, and ≥90% coverage before any milestone ships