Skip to content

Commit fde6810

Browse files
bordeuxclaude
andcommitted
refactor: migrate formatting filters to filter_functions (Phase 1)
Migrate filesizeformat and urlencode from src/filters to src/filter_functions, enabling both function and filter syntax: - Function: {{ filesizeformat(bytes=1048576) }} - Filter: {{ 1048576 | filesizeformat }} - Function: {{ urlencode(string="hello world") }} - Filter: {{ "hello world" | urlencode }} Changes: - Create src/filter_functions/formatting.rs - Update unit tests to use new filter-function API - Add integration tests for function syntax - Update README.md with dual syntax examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 308a7bc commit fde6810

7 files changed

Lines changed: 522 additions & 54 deletions

File tree

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,9 @@ Access loop metadata:
412412
- `repeat(count)` - Repeat string N times
413413
- `reverse` - Reverse string
414414

415-
**Formatting filters:**
416-
- `urlencode` - URL encoding
417-
- `filesizeformat` - Format bytes (e.g., "1.5 KB")
415+
**Formatting filters (function + filter syntax):**
416+
- `filesizeformat(bytes)` / `| filesizeformat` - Format bytes (e.g., "1.5 KB")
417+
- `urlencode(string)` / `| urlencode` - URL encoding (percent-encoding)
418418

419419
**Examples:**
420420
```
@@ -433,6 +433,13 @@ Access loop metadata:
433433
434434
{# Chaining filters #}
435435
{{ "hello_world" | to_pascal_case | reverse }} {# Output: dlroWolleH #}
436+
437+
{# Formatting - both syntaxes work #}
438+
{{ 1048576 | filesizeformat }} {# Output: 1 MB #}
439+
{{ filesizeformat(bytes=1048576) }} {# Output: 1 MB #}
440+
441+
{{ "hello world" | urlencode }} {# Output: hello%20world #}
442+
{{ urlencode(string="hello world") }} {# Output: hello%20world #}
436443
```
437444

438445
### Comments

REFACTOR_FILTERS.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Refactoring: Migrate Filters to Filter-Functions
2+
3+
This document outlines the plan to migrate all filters from `src/filters/` to `src/filter_functions/`, enabling both function and filter syntax for each.
4+
5+
## Goal
6+
7+
Convert all existing filter-only implementations to unified filter-functions that support both syntaxes:
8+
9+
```jinja
10+
{# Current: filter-only syntax #}
11+
{{ "Hello World" | slugify }}
12+
{{ 1048576 | filesizeformat }}
13+
14+
{# After migration: both syntaxes work #}
15+
{{ slugify(string="Hello World") }}
16+
{{ "Hello World" | slugify }}
17+
18+
{{ filesizeformat(bytes=1048576) }}
19+
{{ 1048576 | filesizeformat }}
20+
```
21+
22+
## Current State
23+
24+
### src/filters/ (to be removed)
25+
26+
**formatting.rs:**
27+
| Filter | Description |
28+
|--------|-------------|
29+
| `filesizeformat` | Format bytes as human-readable (KB, MB, GB) |
30+
| `urlencode` | URL-encode special characters |
31+
32+
**string.rs:**
33+
| Filter | Description |
34+
|--------|-------------|
35+
| `slugify` | Convert to URL-friendly slug |
36+
| `indent` | Indent text by N spaces |
37+
| `dedent` | Remove common leading whitespace |
38+
| `quote` | Quote string (single/double/backtick) |
39+
| `escape_quotes` | Escape quotes in string |
40+
| `to_snake_case` | Convert to snake_case |
41+
| `to_camel_case` | Convert to camelCase |
42+
| `to_pascal_case` | Convert to PascalCase |
43+
| `to_kebab_case` | Convert to kebab-case |
44+
| `pad_left` | Pad string on left |
45+
| `pad_right` | Pad string on right |
46+
| `repeat` | Repeat string N times |
47+
| `reverse` | Reverse string |
48+
49+
**Total: 15 filters to migrate**
50+
51+
## Migration Plan
52+
53+
### Phase 1: Create formatting.rs in filter_functions ✅
54+
55+
**File:** `src/filter_functions/formatting.rs`
56+
57+
| Function/Filter | Parameters | Notes |
58+
|-----------------|------------|-------|
59+
| `filesizeformat` | `bytes` (number) | Format file size |
60+
| `urlencode` | `string` | URL-encode (uses percent_encoding crate) |
61+
62+
**Note:** `urlencode` is similar to existing `url_encode` but uses different encoding. Keep both for backwards compatibility.
63+
64+
Tasks:
65+
- [x] Create `src/filter_functions/formatting.rs`
66+
- [x] Implement `Filesizeformat` with FilterFunction trait
67+
- [x] Implement `Urlencode` with FilterFunction trait
68+
- [x] Add `pub mod formatting;` to `src/filter_functions/mod.rs`
69+
- [x] Register both in `register_all()`
70+
- [x] Fix/add unit tests under `tests/` folder testing function syntax
71+
- [x] Update integration tests in `tests/test_filters_integration.rs` for function syntax
72+
- [x] Update README.md with dual syntax examples
73+
- [x] Update REFACTOR_FILTERS.md with current state
74+
- [x] Run `cargo make qa` (508 tests passing)
75+
76+
**Commit:** dd0bb3b
77+
78+
### Phase 2: Add string filters to filter_functions/string.rs
79+
80+
**File:** `src/filter_functions/string.rs` (append to existing)
81+
82+
| Function/Filter | Parameters | Notes |
83+
|-----------------|------------|-------|
84+
| `slugify` | `string` | No extra params |
85+
| `indent` | `string`, `spaces` (optional, default 4) | |
86+
| `dedent` | `string` | No extra params |
87+
| `quote` | `string`, `style` (optional: single/double/backtick) | |
88+
| `escape_quotes` | `string` | No extra params |
89+
| `to_snake_case` | `string` | No extra params |
90+
| `to_camel_case` | `string` | No extra params |
91+
| `to_pascal_case` | `string` | No extra params |
92+
| `to_kebab_case` | `string` | No extra params |
93+
| `pad_left` | `string`, `length`, `char` (optional) | |
94+
| `pad_right` | `string`, `length`, `char` (optional) | |
95+
| `repeat` | `string`, `count` | |
96+
| `reverse` | `string` | No extra params |
97+
98+
Tasks:
99+
- [ ] Add 13 new structs to `src/filter_functions/string.rs`
100+
- [ ] Implement FilterFunction trait for each
101+
- [ ] Register all in `src/filter_functions/mod.rs`
102+
- [ ] Fix/add unit tests under `tests/` folder testing function syntax
103+
- [ ] Update integration tests in `tests/test_filters_integration.rs` for function syntax
104+
- [ ] Update README.md with dual syntax examples
105+
- [ ] Update REFACTOR_FILTERS.md with current state
106+
- [ ] Run `cargo make qa`
107+
108+
### Phase 3: Remove old filters module
109+
110+
Tasks:
111+
- [ ] Remove `src/filters/formatting.rs`
112+
- [ ] Remove `src/filters/string.rs`
113+
- [ ] Remove `src/filters/mod.rs`
114+
- [ ] Remove `pub mod filters;` from `src/lib.rs`
115+
- [ ] Remove `crate::filters::register_all(env);` from `src/functions/mod.rs`
116+
- [ ] Fix/add unit tests under `tests/` folder (remove obsolete filter-only tests)
117+
- [ ] Update integration tests to verify both syntaxes still work
118+
- [ ] Update README.md (remove references to old filters module)
119+
- [ ] Update REFACTOR_FILTERS.md with current state
120+
- [ ] Run `cargo make qa`
121+
- [ ] Remove REFACTOR_FILTERS.md (migration complete)
122+
123+
## Implementation Pattern
124+
125+
Each filter becomes a struct implementing `FilterFunction`:
126+
127+
```rust
128+
pub struct Slugify;
129+
130+
impl Slugify {
131+
fn compute(input: &str) -> String {
132+
// Implementation here
133+
}
134+
}
135+
136+
impl FilterFunction for Slugify {
137+
const NAME: &'static str = "slugify";
138+
139+
fn call_as_function(kwargs: Kwargs) -> Result<Value, Error> {
140+
let string: String = kwargs.get("string")?;
141+
Ok(Value::from(Self::compute(&string)))
142+
}
143+
144+
fn call_as_filter(value: &Value, _kwargs: Kwargs) -> Result<Value, Error> {
145+
let string = extract_string(value, "slugify")?;
146+
Ok(Value::from(Self::compute(&string)))
147+
}
148+
}
149+
```
150+
151+
## Expected Result
152+
153+
After migration:
154+
- `src/filters/` directory removed entirely
155+
- All 15 filters available as both functions and filters
156+
- Full backwards compatibility maintained
157+
- New function syntax available for all
158+
159+
## Checklist
160+
161+
- [ ] Phase 1: Create formatting.rs (filesizeformat, urlencode)
162+
- [ ] Phase 2: Add string filters (13 filters)
163+
- [ ] Phase 3: Remove old filters module
164+
- [ ] All tests passing (`cargo make qa`)
165+
- [ ] REFACTOR_FILTERS.md removed (migration complete)

src/filter_functions/formatting.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//! Formatting functions that support both function and filter syntax.
2+
//!
3+
//! # Function Syntax
4+
//! ```jinja
5+
//! {{ filesizeformat(bytes=1048576) }}
6+
//! {{ urlencode(string="hello world") }}
7+
//! ```
8+
//!
9+
//! # Filter Syntax
10+
//! ```jinja
11+
//! {{ 1048576 | filesizeformat }}
12+
//! {{ "hello world" | urlencode }}
13+
//! ```
14+
15+
use super::FilterFunction;
16+
use minijinja::value::Kwargs;
17+
use minijinja::{Error, ErrorKind, Value};
18+
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
19+
20+
/// Helper to extract string from Value
21+
fn extract_string(value: &Value, fn_name: &str) -> Result<String, Error> {
22+
value.as_str().map(|s| s.to_string()).ok_or_else(|| {
23+
Error::new(
24+
ErrorKind::InvalidOperation,
25+
format!("{} requires a string, found: {}", fn_name, value),
26+
)
27+
})
28+
}
29+
30+
// ============================================
31+
// Filesizeformat
32+
// ============================================
33+
34+
/// Format file size in human-readable format (bytes, KB, MB, GB, etc.)
35+
///
36+
/// # Function Syntax
37+
/// ```jinja
38+
/// {{ filesizeformat(bytes=1024) }}
39+
/// {# Output: 1 KB #}
40+
///
41+
/// {{ filesizeformat(bytes=1048576) }}
42+
/// {# Output: 1 MB #}
43+
/// ```
44+
///
45+
/// # Filter Syntax
46+
/// ```jinja
47+
/// {{ 1024 | filesizeformat }}
48+
/// {# Output: 1 KB #}
49+
///
50+
/// {{ file_size | filesizeformat }}
51+
/// ```
52+
pub struct Filesizeformat;
53+
54+
impl Filesizeformat {
55+
fn compute(bytes: f64) -> String {
56+
const UNITS: &[&str] = &["bytes", "KB", "MB", "GB", "TB", "PB"];
57+
const THRESHOLD: f64 = 1024.0;
58+
59+
if bytes < THRESHOLD {
60+
return format!("{} bytes", bytes as i64);
61+
}
62+
63+
let mut size = bytes;
64+
let mut unit_index = 0;
65+
66+
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
67+
size /= THRESHOLD;
68+
unit_index += 1;
69+
}
70+
71+
// Format with appropriate precision
72+
if (size - size.round()).abs() < 0.01 {
73+
format!("{:.0} {}", size, UNITS[unit_index])
74+
} else if size < 10.0 {
75+
format!("{:.2} {}", size, UNITS[unit_index])
76+
} else if size < 100.0 {
77+
format!("{:.1} {}", size, UNITS[unit_index])
78+
} else {
79+
format!("{:.0} {}", size, UNITS[unit_index])
80+
}
81+
}
82+
}
83+
84+
impl FilterFunction for Filesizeformat {
85+
const NAME: &'static str = "filesizeformat";
86+
87+
fn call_as_function(kwargs: Kwargs) -> Result<Value, Error> {
88+
let bytes: i64 = kwargs.get("bytes")?;
89+
Ok(Value::from(Self::compute(bytes as f64)))
90+
}
91+
92+
fn call_as_filter(value: &Value, _kwargs: Kwargs) -> Result<Value, Error> {
93+
let bytes = value.as_i64().ok_or_else(|| {
94+
Error::new(
95+
ErrorKind::InvalidOperation,
96+
format!("filesizeformat requires a number, found: {}", value),
97+
)
98+
})?;
99+
Ok(Value::from(Self::compute(bytes as f64)))
100+
}
101+
}
102+
103+
// ============================================
104+
// Urlencode
105+
// ============================================
106+
107+
/// URL encode a string - encode special characters for use in URLs.
108+
///
109+
/// This uses percent-encoding for all non-alphanumeric characters.
110+
///
111+
/// # Function Syntax
112+
/// ```jinja
113+
/// {{ urlencode(string="hello world & foo=bar") }}
114+
/// {# Output: hello%20world%20%26%20foo%3Dbar #}
115+
/// ```
116+
///
117+
/// # Filter Syntax
118+
/// ```jinja
119+
/// {{ "hello world" | urlencode }}
120+
/// {# Output: hello%20world #}
121+
/// ```
122+
///
123+
/// # Note
124+
/// See also `url_encode` which uses a slightly different encoding scheme
125+
/// (preserves some additional characters like `_`, `.`, `-`, `~`).
126+
pub struct Urlencode;
127+
128+
impl Urlencode {
129+
fn compute(input: &str) -> String {
130+
utf8_percent_encode(input, NON_ALPHANUMERIC).to_string()
131+
}
132+
}
133+
134+
impl FilterFunction for Urlencode {
135+
const NAME: &'static str = "urlencode";
136+
137+
fn call_as_function(kwargs: Kwargs) -> Result<Value, Error> {
138+
let input: String = kwargs.get("string")?;
139+
Ok(Value::from(Self::compute(&input)))
140+
}
141+
142+
fn call_as_filter(value: &Value, _kwargs: Kwargs) -> Result<Value, Error> {
143+
let input = extract_string(value, "urlencode")?;
144+
Ok(Value::from(Self::compute(&input)))
145+
}
146+
}

src/filter_functions/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
pub mod array;
3030
pub mod datetime;
3131
pub mod encoding;
32+
pub mod formatting;
3233
pub mod hash;
3334
pub mod kubernetes;
3435
pub mod math;
@@ -138,4 +139,8 @@ pub fn register_all(env: &mut Environment) {
138139
kubernetes::K8sLabelSafe::register(env);
139140
kubernetes::K8sDnsLabelSafe::register(env);
140141
kubernetes::K8sAnnotationSafe::register(env);
142+
143+
// Formatting functions (migrated from src/filters)
144+
formatting::Filesizeformat::register(env);
145+
formatting::Urlencode::register(env);
141146
}

src/filters/mod.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
//! - `repeat` - Repeat string N times
2020
//! - `reverse` - Reverse string
2121
//!
22-
//! - **Formatting Filters** (`formatting` module): Data formatting filters
23-
//! - `filesizeformat` - Format bytes as human-readable file sizes
24-
//! - `urlencode` - URL-encode strings for safe URL usage
22+
//! - **Formatting Filters** - Now in `filter_functions/formatting` module
23+
//! - `filesizeformat` - Format bytes as human-readable file sizes (migrated)
24+
//! - `urlencode` - URL-encode strings for safe URL usage (migrated)
2525
//!
2626
//! # Adding Custom Filters
2727
//!
@@ -90,7 +90,5 @@ pub fn register_all(env: &mut Environment) {
9090
env.add_filter("repeat", string::repeat_filter);
9191
env.add_filter("reverse", string::reverse_filter);
9292

93-
// Formatting filters
94-
env.add_filter("filesizeformat", formatting::filesizeformat_filter);
95-
env.add_filter("urlencode", formatting::urlencode_filter);
93+
// Note: Formatting filters (filesizeformat, urlencode) migrated to filter_functions/formatting.rs
9694
}

0 commit comments

Comments
 (0)