rok-utils 0.2.2

Laravel/AdonisJS-inspired utility helpers for the Rok ecosystem
Documentation

rok-utils

crates.io docs.rs CI codecov License: MIT

A Laravel/AdonisJS-inspired utility crate for Rust with zero-bloat, ergonomic helpers.

Version: 0.2.0
Edition: Rust 2021
MSRV: 1.92


Quick Start

[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();

Features

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

Feature Flags

rok-utils = { version = "0.2", features = ["dates", "crypto", "ids", "json", "random"] }


Documentation


Quality Gates

cargo fmt --check

cargo clippy --all-features -- -D warnings

cargo test --all-features

cargo test --doc


License

MIT License - see LICENSE

  1. Overview & Philosophy
  2. Crate Configuration — Cargo.toml
  3. Module Structure
  4. String Utilities — str.rs
  5. Array / Collection Utilities — arr.rs
  6. Error Handling — errors.rs
  7. Data Utilities — data.rs
  8. Functional Patterns — fp.rs
  9. Fluent Builder API — fluent.rs
  10. Type Helpers — types.rs
  11. Testing Strategy
  12. CI/CD Pipeline
  13. Milestone Roadmap

1. Overview & Philosophy

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.

Design Principles

  • Fluent API first — chainable builder pattern mirroring Laravel's Str::of() / AdonisJS's string.camelCase().truncate() pipeline
  • No runtime panics — all functions return Result<T, RokError> or Option<T>; never unwrap() 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-safe escapeHTML / encodeSymbols approach
  • < 400 lines per file — every module is a single focused concern; no "god files"
  • Fully tested — unit tests + proptest for invariant testing + doctests as living documentation

Comparison Table

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)

2. Crate Configuration — Cargo.toml

[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

Feature Flag Usage

// 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::*;

3. Module Structure

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

4. String Utilities — str.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.

4.1 Case Conversion — str/case.rs

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

Implementation Notes

// 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

4.2 Transform Functions — str/transform.rs

/// 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>

4.3 Inspection Functions — str/inspect.rs

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

4.4 Random Strings — str/random.rs (feature = "random")

/// 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

4.5 Pluralization — str/plural.rs (feature = "unicode")

/// 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) -> bool

4.6 Fluent Builder — str/fluent.rs

Mirrors 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 ==="

5. Array / Collection Utilities — arr.rs

Inspired by Laravel's Collection API and AdonisJS array helpers. All functions are generic over T and use Rust's Iterator trait internally.

5.1 Transformation — arr/ops.rs

/// 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)>

5.2 Query — arr/query.rs

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

5.3 Set Operations — arr/set.rs

/// 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>

6. Error Handling — errors.rs

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.

6.1 Core Error Enum — errors/kinds.rs

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
        )
    }
}

6.2 Context & Wrapping — errors/context.rs

/// 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());

6.3 Error Macros — errors/macros.rs

/// 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()))
    };
}

7. Data Utilities — data.rs

7.1 Numbers — data/numbers.rs

/// 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) -> f64

7.2 Dates — data/dates.rs (feature = "dates")

use 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)

7.3 IDs — data/ids.rs (feature = "ids")

/// 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

7.4 Hashing — data/hashing.rs (feature = "crypto")

/// 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) -> bool

8. Functional Patterns — fp.rs

Inspired by Laravel's pipeline, AdonisJS middleware chaining, and standard functional programming utilities.

8.1 Core Combinators — fp/compose.rs

/// 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>

8.2 Lazy & Memoization — fp/lazy.rs

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

8.3 Helper Macros — fp/macros.rs

/// 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 }
    };
}

9. Fluent Builder API — fluent.rs

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
}

10. Type Helpers — types.rs

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::Value

11. Testing Strategy

11.1 Unit Tests (inline per module)

Every public function has at least:

  • A happy-path test
  • An edge case (empty string, zero, negative numbers, Unicode input)
  • A #[should_panic] or Err assertion 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");
    }
}

11.2 Property-Based Tests (proptest)

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));
        }
    }
}

11.3 Benchmarks (Criterion)

// 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);

11.4 Doctests as Living Docs

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 { ... }

12. CI/CD Pipeline

# .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@v3

13. Milestone Roadmap

Milestone 1 — Foundation (Publishing 0.1.0)

Goal: 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.rsStr::of() builder with 30+ chainable methods and when* conditionals
  • arr/ops.rs, arr/query.rs, arr/set.rs — full collection API
  • errors/kinds.rsRokError enum with AdonisJS-style codes + HTTP status
  • errors/context.rsResultExt trait and wrap_error
  • fp/compose.rspipe, tap, compose, retry
  • CI green: fmt + clippy + tests + doctests
  • Code coverage ≥ 90%
  • All files ≤ 400 lines

Milestone 2 — Feature Modules (0.2.0)

Goal: Optional modules enabled via feature flags are ready for production.

  • data/numbers.rsformat_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 secure random() and password() (feature = "random")
  • str/plural.rs — pluralize, singular, is_plural (feature = "unicode")
  • fp/lazy.rsLazy<T>, memoize, once
  • types/guards.rs — JSON type guards and dot-path access

Milestone 3 — CLI Integration (rok-cli uses rok-utils)

Goal: Replace all ad-hoc string handling in rok-cli with rok-utils.

  • Replace internal case conversion with rok_utils::str::to_snake_case etc.
  • Use Str::of() fluent builder in code generation templates
  • Wire RokError into CLI error reporting pipeline
  • rok generate model <Name> uses to_pascal_case + to_snake_case consistently
  • Benchmark: no regression vs. previous hand-rolled string code

Milestone 4 — SQLx / ORM Integration

Goal: rok-orm macro system uses rok-utils for identifier generation.

  • model! macro uses to_snake_case for table names
  • migrate! uses uuid_v7 for migration timestamps
  • query! builder uses Collect<T> for result mapping
  • Error propagation: database errors wrap to RokError::Internal

Milestone 5 — Docs Site & Stable API (1.0.0)

Goal: Public API is frozen and fully documented.

  • cargo doc --all-features --no-deps generates complete API reference
  • Deploy to GitHub Pages via CI
  • Add #[non_exhaustive] to RokError enum
  • Write migration guide from 0.x to 1.0
  • All public types implement Debug + Clone + Send + Sync where applicable
  • Minimum Supported Rust Version (MSRV) pinned to stable - 2 releases

Summary

This plan delivers a rok-utils crate that:

  1. Mirrors battle-tested APIs — Laravel's Str and AdonisJS's string helpers are the benchmark; every function name and behavior maps to a known analogue
  2. Ergonomic by default — the fluent Str::of() builder makes complex transformations readable and composable without intermediate variables
  3. Error-first design — AdonisJS-style typed error codes (E_NOT_FOUND, E_VALIDATION_FAILURE) with HTTP status semantics baked in from day one
  4. Zero-bloat — feature flags ensure consumers pay only for what they use
  5. Production-ready — proptest invariants, criterion benchmarks, and ≥90% coverage before any milestone ships