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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions crates/cachekit/src/intents.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Intent-based cache presets.
//!
//! Pre-configured factory methods that build a [`CacheKit`] client from a
//! single declarative call. Each intent sets sensible defaults for a specific
//! use case and returns a [`CacheKitBuilder`] so callers can override any
//! setting before building.
//!
//! | Intent | Backend | L1 | Encryption | Default TTL |
//! |------------|-----------|------|------------|-------------|
//! | `minimal` | Redis | Off | No | 300 s |
//! | `production` | Redis | On | No | 600 s |
//! | `encrypted` | Redis | On | AES-256-GCM | 600 s |
//! | `io` | cachekit.io | On | No | 3 600 s |

use std::time::Duration;

use crate::client::{CacheKit, CacheKitBuilder, SharedBackend};
use crate::error::CachekitError;

// ── SharedBackend wrapping ───────────────────────────────────────────────────

#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
std::sync::Arc::new(b)
}

#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
std::rc::Rc::new(b)
}

// ── Intent presets ───────────────────────────────────────────────────────────

impl CacheKit {
/// **Minimal** — speed-first Redis cache, no extras.
///
/// * Backend: Redis (connects eagerly)
/// * L1 cache: **off**
/// * Encryption: **no**
/// * Default TTL: **300 s**
///
/// Good for: product catalogs, public data, development.
///
/// # Errors
///
/// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
///
/// # Example
///
/// ```no_run
/// # async fn example() -> Result<(), cachekit::CachekitError> {
/// let cache = cachekit::CacheKit::minimal("redis://localhost:6379").await?
/// .namespace("myapp")
/// .build()?;
/// # Ok(())
/// # }
/// ```
#[cfg(feature = "redis")]
pub async fn minimal(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::redis::RedisBackend::builder()
.url(redis_url)
.build()?;
drop(backend.connect().await?);
Comment on lines +60 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Intent resilience behavior is not differentiated.

Line 60, Line 96, and Line 141 all construct Redis the same way (builder().url().build() + eager connect). This does not encode the required intent differences for circuit-breaker/retry behavior, so minimal vs production/encrypted cannot reliably match the stated objective semantics.

Also applies to: 96-99, 141-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/cachekit/src/intents.rs` around lines 60 - 63, The code currently
constructs Redis backends with the same pattern
(RedisBackend::builder().url(...).build() and an eager connect().await) which
fails to encode intent-specific resilience (minimal vs production/encrypted);
update each construction site to apply intent-specific configuration instead of
identical builds: use RedisBackend::builder().url(...).retry_policy(...) /
.circuit_breaker(...) for production, add TLS options for encrypted (e.g.,
.tls_enabled(true) or equivalent), and use a lightweight health check or
deferred connect for minimal (avoid forcing connect().await); remove or replace
the unconditional drop(connect().await?) calls and ensure the builder methods
(retry_policy, circuit_breaker, tls_enabled or similar) are used on the
RedisBackend::builder() invocations to encode semantics for
minimal/production/encrypted intents.


Ok(CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(300))
.no_l1())
}

/// **Production** — reliability-first Redis cache with L1.
///
/// * Backend: Redis (connects eagerly)
/// * L1 cache: **on** (1 000 entries)
/// * Encryption: **no**
/// * Default TTL: **600 s**
///
/// Good for: user sessions, API responses, production services.
///
/// # Errors
///
/// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
///
/// # Example
///
/// ```no_run
/// # async fn example() -> Result<(), cachekit::CachekitError> {
/// let cache = cachekit::CacheKit::production("redis://localhost:6379").await?
/// .namespace("api")
/// .build()?;
/// # Ok(())
/// # }
/// ```
#[cfg(feature = "redis")]
pub async fn production(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::redis::RedisBackend::builder()
.url(redis_url)
.build()?;
drop(backend.connect().await?);

Ok(CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(600))
.l1_capacity(1000))
}

/// **Encrypted** — zero-knowledge encrypted Redis cache.
///
/// * Backend: Redis (connects eagerly)
/// * L1 cache: **on** (1 000 entries, stores ciphertext)
/// * Encryption: **AES-256-GCM** with HKDF-SHA256
/// * Default TTL: **600 s**
/// * Tenant ID: `"default"` (override via
/// [`.encryption_from_bytes()`](CacheKitBuilder::encryption_from_bytes))
///
/// Good for: PII, payments, GDPR/HIPAA-sensitive data.
///
/// `master_key` must be at least 32 raw bytes.
///
/// # Errors
///
/// Returns [`CachekitError`] if the URL is invalid, Redis is unreachable,
/// or the master key is too short.
///
/// # Example
///
/// ```no_run
/// # async fn example() -> Result<(), cachekit::CachekitError> {
/// let key = b"my_32_byte_production_key_here!!";
/// let cache = cachekit::CacheKit::encrypted("redis://localhost:6379", key).await?
/// .build()?;
/// let encrypted = cache.secure()?;
/// # Ok(())
/// # }
/// ```
#[cfg(all(feature = "redis", feature = "encryption"))]
pub async fn encrypted(
redis_url: &str,
master_key: &[u8],
) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::redis::RedisBackend::builder()
.url(redis_url)
.build()?;
drop(backend.connect().await?);

CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(600))
.l1_capacity(1000)
.encryption_from_bytes(master_key, "default")
}

/// **CachekitIO** — managed SaaS cache, zero infrastructure.
///
/// * Backend: [cachekit.io](https://cachekit.io) HTTP API
/// * L1 cache: **on** (1 000 entries)
/// * Encryption: **no** (add via
/// [`.encryption()`](CacheKitBuilder::encryption))
/// * Default TTL: **3 600 s**
///
/// Good for: serverless, edge compute, managed caching without Redis.
///
/// # Errors
///
/// Returns [`CachekitError`] if `api_key` is empty.
///
/// # Example
///
/// ```no_run
/// # fn example() -> Result<(), cachekit::CachekitError> {
/// let cache = cachekit::CacheKit::io("ck_live_abc123")?
/// .namespace("edge")
/// .build()?;
/// # Ok(())
/// # }
/// ```
#[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
pub fn io(api_key: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::cachekitio::CachekitIO::builder()
.api_key(api_key)
.build()?;

Ok(CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(3600))
.l1_capacity(1000))
}
}
3 changes: 3 additions & 0 deletions crates/cachekit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub mod session;
/// SSRF-safe URL validation for CachekitIO endpoints.
pub mod url_validator;

/// Intent-based cache presets (`CacheKit::minimal`, `::production`, `::encrypted`, `::io`).
mod intents;

/// Client-side AES-256-GCM encryption with HKDF key derivation.
#[cfg(feature = "encryption")]
pub mod encryption;
Expand Down
98 changes: 98 additions & 0 deletions crates/cachekit/tests/intent_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! Tests for intent-based cache presets.
//!
//! The async intents (minimal, production, encrypted) require a Redis
//! connection, so we test parameter validation through the underlying
//! builders. The sync io() intent can be tested end-to-end.
//!
//! Run with:
//! cargo test --test intent_tests --features redis,encryption,cachekitio,l1

mod common;

use std::time::Duration;

// ── minimal / production (Redis URL validation) ──────────────────────────────

#[cfg(feature = "redis")]
mod redis_intents {
#[test]
fn accepts_valid_url() {
let backend = cachekit::backend::redis::RedisBackend::builder()
.url("redis://localhost:6379")
.build();
assert!(backend.is_ok());
}

#[test]
fn accepts_url_with_password() {
let backend = cachekit::backend::redis::RedisBackend::builder()
.url("redis://:secret@host:6379/0")
.build();
assert!(backend.is_ok());
}

#[test]
fn rejects_empty_url() {
let backend = cachekit::backend::redis::RedisBackend::builder()
.url("")
.build();
assert!(backend.is_err());
}
}

// ── encrypted (encryption key validation) ────────────────────────────────────

#[cfg(all(feature = "redis", feature = "encryption"))]
mod encrypted_intent {
use crate::common::MockBackend;

#[test]
fn rejects_short_master_key() {
let result = cachekit::CacheKitBuilder::default()
.backend(MockBackend::shared())
.encryption_from_bytes(b"too_short", "tenant");
assert!(
result.is_err(),
"master key under 16 bytes must be rejected"
);
}

#[test]
fn accepts_valid_master_key() {
let result = cachekit::CacheKitBuilder::default()
.backend(MockBackend::shared())
.encryption_from_bytes(b"test_master_key_32_bytes_long!!!", "tenant");
assert!(result.is_ok());
}
}

// ── io (full end-to-end, no network needed) ──────────────────────────────────

#[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
mod io_intent {
use super::*;
use cachekit::CacheKit;

#[test]
fn builds_with_valid_key() {
let builder = CacheKit::io("ck_live_test123");
assert!(builder.is_ok());
assert!(builder.unwrap().build().is_ok());
}

#[test]
fn rejects_empty_key() {
assert!(CacheKit::io("").is_err());
}

#[test]
fn allows_overrides() {
let cache = CacheKit::io("ck_live_test123")
.unwrap()
.default_ttl(Duration::from_secs(60))
.namespace("test")
.no_l1()
.build();
assert!(cache.is_ok());
}
}