Skip to main content

rusty_figlet/
filter.rs

1//! `RenderGrid` + `FilterChain` (E012 US1/US5 — FR-002, FR-003, FR-004).
2//!
3//! This module hosts the typed grid that filters operate on, the
4//! [`Filter`] enum enumerating the 10 supported transformations, the
5//! [`FilterChain`] orchestrator, and the per-filter pure-function
6//! implementations. Each individual filter is gated behind its leaf
7//! feature (`filter-crop`, `filter-gay`, `filter-metal`, `filter-flip`,
8//! `filter-flop`, `filter-rotate`, `filter-border`) per ADR-0006 +
9//! plan §Cargo Feature Surface; the [`Filter::Nothing`] identity has no
10//! leaf and is always available.
11//!
12//! ## Design constraints
13//!
14//! - **Immutability (AD-002)** — every filter takes an owned [`RenderGrid`]
15//!   and returns a new owned grid. No interior mutability, no shared
16//!   borrows, no in-place transforms.
17//! - **Bounded cell footprint (AD-011)** — [`Cell`] is ~16 bytes; the
18//!   grid memory is `O(w·h)` and a chain of `n` filters costs `O(n·w·h)`
19//!   (HINT-006 + FR-030).
20//! - **No upstream-source consultation** — implementations derived from
21//!   the toilet(1) manpage and observed outputs; recorded under
22//!   `docs/tlf-derivation.md`.
23
24use crate::error::FigletError;
25
26/// Maximum filter-name length accepted by [`FilterChain::parse`] (spec Edge Cases).
27///
28/// Names longer than this byte count are rejected with
29/// [`FigletError::UnknownFilter`] regardless of contents — guards against
30/// adversarial `-F` chains and keeps the error path O(1).
31const MAX_FILTER_NAME_BYTES: usize = 64;
32
33/// Canonical list of valid filter names (declaration order).
34///
35/// Used by [`FilterChain::parse`] for lookup and surfaced in
36/// [`FigletError::UnknownFilter::available`] so the CLI can enumerate
37/// the supported set in a diagnostic.
38const FILTER_NAMES: &[&str] = &[
39    "crop",
40    "gay",
41    "metal",
42    "flip",
43    "flop",
44    "rotate180",
45    "rotateleft",
46    "rotateright",
47    "border",
48    "nothing",
49];
50
51/// Color carried by a [`Cell`] — bounded footprint per AD-011.
52///
53/// Three representations cover the SGR surfaces that v0.3.0 emits:
54///
55/// - [`Color::Named`] for the 16-color palette (`\x1b[30m`..`\x1b[37m`,
56///   `\x1b[90m`..`\x1b[97m`) — the toilet 0.3-1 floor;
57/// - [`Color::Index`] for 256-color (`\x1b[38;5;Nm`) — Phase 6;
58/// - [`Color::Rgb`] for 24-bit truecolor (`\x1b[38;2;R;G;Bm`) — Phase 6.
59///
60/// The default is [`Color::default`] (white-on-default-bg) so a freshly
61/// constructed [`Cell`] needs no explicit color call.
62///
63/// The enum is `#[non_exhaustive]` so adding e.g. a fully-typed
64/// 88-color palette (rare but exists in retro terminals) remains
65/// non-breaking under SemVer.
66#[non_exhaustive]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum Color {
69    /// One of the 16 ANSI named colors (0..=15).
70    Named(NamedColor),
71    /// One of the 256 indexed palette colors (0..=255).
72    Index(u8),
73    /// 24-bit truecolor triple.
74    Rgb(u8, u8, u8),
75}
76
77impl Default for Color {
78    fn default() -> Self {
79        // White-on-default-bg matches the SGR default for a plain TTY.
80        Self::Named(NamedColor::White)
81    }
82}
83
84/// One of the 16 ANSI named colors.
85///
86/// Stored as a single-byte enum to keep [`Cell`]'s footprint within the
87/// AD-011 ~16-byte budget regardless of `Color` variant.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89#[allow(missing_docs)]
90pub enum NamedColor {
91    Black,
92    Red,
93    Green,
94    Yellow,
95    Blue,
96    Magenta,
97    Cyan,
98    White,
99    BrightBlack,
100    BrightRed,
101    BrightGreen,
102    BrightYellow,
103    BrightBlue,
104    BrightMagenta,
105    BrightCyan,
106    BrightWhite,
107}
108
109/// A single rendered cell: a character plus optional color attributes.
110///
111/// Footprint is bounded by AD-011 to ~16 bytes on 64-bit targets:
112/// - `ch: char` — 4 bytes
113/// - `fg: Color` — 4 bytes (variant tag + 3-byte payload for `Rgb`)
114/// - `bg: Option<Color>` — 5 bytes (tag + 4-byte `Color`)
115/// - `attrs: u8` — 1 byte (bold / underline / reverse bitfield)
116///
117/// The remaining ~2 bytes are alignment padding.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub struct Cell {
120    /// Glyph character at this cell.
121    pub ch: char,
122    /// Foreground color.
123    pub fg: Color,
124    /// Optional background color (`None` = terminal default bg).
125    pub bg: Option<Color>,
126    /// SGR attribute bitfield: bit 0 = bold, bit 1 = underline, bit 2 = reverse.
127    pub attrs: u8,
128}
129
130impl Cell {
131    /// Construct a cell with the given glyph; default foreground (white),
132    /// no background, no attributes.
133    #[must_use]
134    pub fn new(ch: char) -> Self {
135        Self {
136            ch,
137            fg: Color::default(),
138            bg: None,
139            attrs: 0,
140        }
141    }
142
143    /// Blank cell — a space with default colors.
144    #[must_use]
145    pub fn blank() -> Self {
146        Self::new(' ')
147    }
148
149    /// `true` if the cell is visually blank (space character).
150    ///
151    /// Used by [`apply_crop`] to detect all-blank rows/columns. Color
152    /// and attribute bits are ignored — a colored space is still blank.
153    #[must_use]
154    pub fn is_blank(&self) -> bool {
155        self.ch == ' '
156    }
157}
158
159/// A 2D grid of [`Cell`]s with explicit `width` × `height` dimensions.
160///
161/// Filters operate on owned `RenderGrid`s and return new owned grids per
162/// AD-002 (immutable transformations). Construction normalizes ragged
163/// row vectors to a rectangular shape padded with [`Cell::blank`] so
164/// downstream filters can assume `cells[y].len() == width` for every
165/// row.
166///
167/// Allocated as `Vec<Vec<Cell>>` rather than a single flat `Vec<Cell>`
168/// because the filter implementations (transpose, rotate, flip) work
169/// row-major and benefit from being able to `.swap()`, `.reverse()`, and
170/// `.collect()` per-row without index arithmetic.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RenderGrid {
173    /// Cells in row-major order. `cells[y][x]` is the cell at column `x`,
174    /// row `y`. Every row is exactly `width` cells long after construction.
175    pub cells: Vec<Vec<Cell>>,
176    /// Number of columns.
177    pub width: u32,
178    /// Number of rows.
179    pub height: u32,
180}
181
182impl RenderGrid {
183    /// Construct an empty grid (0×0).
184    #[must_use]
185    pub fn empty() -> Self {
186        Self {
187            cells: Vec::new(),
188            width: 0,
189            height: 0,
190        }
191    }
192
193    /// Construct a rectangular grid sized `width` × `height`, filled
194    /// with blank cells.
195    #[must_use]
196    pub fn blank(width: u32, height: u32) -> Self {
197        let w = width as usize;
198        let h = height as usize;
199        let cells = (0..h).map(|_| vec![Cell::blank(); w]).collect();
200        Self {
201            cells,
202            width,
203            height,
204        }
205    }
206
207    /// Build a grid from a vector of rows; ragged rows are padded with
208    /// [`Cell::blank`] up to the longest row's length.
209    #[must_use]
210    pub fn from_rows(mut rows: Vec<Vec<Cell>>) -> Self {
211        let width = rows.iter().map(Vec::len).max().unwrap_or(0);
212        for row in rows.iter_mut() {
213            if row.len() < width {
214                row.resize(width, Cell::blank());
215            }
216        }
217        let height = rows.len();
218        Self {
219            cells: rows,
220            width: width as u32,
221            height: height as u32,
222        }
223    }
224
225    /// Construct a grid from a multi-line `&str`. Each line is one row;
226    /// each character is one cell with default color. Convenient for
227    /// tests and the FLF/TLF render pipeline's `Vec<String>` row output.
228    #[must_use]
229    pub fn from_text_rows(rows: &[String]) -> Self {
230        let cells: Vec<Vec<Cell>> = rows
231            .iter()
232            .map(|line| line.chars().map(Cell::new).collect())
233            .collect();
234        Self::from_rows(cells)
235    }
236}
237
238/// The 10 supported toilet-compatible filters (FR-003).
239///
240/// Each variant maps to a pure function on owned [`RenderGrid`]s
241/// (AD-002). Names match toilet(1)'s `-F` chain vocabulary 1:1 (`crop`,
242/// `gay`, `metal`, `flip`, `flop`, `rotate180`, `rotateleft`,
243/// `rotateright`, `border`, plus the always-available `nothing`
244/// identity).
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
246pub enum Filter {
247    /// Trim surrounding all-blank rows and columns.
248    Crop,
249    /// Per-column rainbow color sweep (the toilet `--gay` aesthetic).
250    Gay,
251    /// Blue / gray metallic gradient.
252    Metal,
253    /// Horizontal mirror (reverse each row's columns).
254    Flip,
255    /// Vertical mirror (reverse the order of rows).
256    Flop,
257    /// Rotate 180° (combination of flip + flop).
258    Rotate180,
259    /// Rotate 90° counter-clockwise (transpose + flip).
260    RotateLeft,
261    /// Rotate 90° clockwise (transpose + flop).
262    RotateRight,
263    /// Draw a Unicode box-drawing border around the grid.
264    Border,
265    /// No-op identity. Always available (no leaf gate).
266    Nothing,
267}
268
269impl Filter {
270    /// Returns the canonical lowercase name parsed from a `-F` chain.
271    #[must_use]
272    pub const fn name(&self) -> &'static str {
273        match self {
274            Filter::Crop => "crop",
275            Filter::Gay => "gay",
276            Filter::Metal => "metal",
277            Filter::Flip => "flip",
278            Filter::Flop => "flop",
279            Filter::Rotate180 => "rotate180",
280            Filter::RotateLeft => "rotateleft",
281            Filter::RotateRight => "rotateright",
282            Filter::Border => "border",
283            Filter::Nothing => "nothing",
284        }
285    }
286
287    /// Map a parsed segment name to a [`Filter`] variant.
288    ///
289    /// Case-sensitive lowercase match — toilet(1) is documented as
290    /// lowercase-only and we preserve that semantic. Returns `None` for
291    /// unknown names; the caller turns this into a
292    /// [`FigletError::UnknownFilter`].
293    fn from_name(name: &str) -> Option<Filter> {
294        Some(match name {
295            "crop" => Filter::Crop,
296            "gay" => Filter::Gay,
297            "metal" => Filter::Metal,
298            "flip" => Filter::Flip,
299            "flop" => Filter::Flop,
300            "rotate180" => Filter::Rotate180,
301            "rotateleft" => Filter::RotateLeft,
302            "rotateright" => Filter::RotateRight,
303            "border" => Filter::Border,
304            "nothing" => Filter::Nothing,
305            _ => return None,
306        })
307    }
308}
309
310/// Ordered list of [`Filter`]s applied left-to-right in
311/// [`FilterChain::apply`] (FR-004).
312///
313/// ## Cost bound
314///
315/// A chain of `n` filters applied to a `w × h` grid runs in
316/// `O(n · w · h)` time and allocates at most `O(w · h)` per step
317/// (per AD-002 + AD-007 + HINT-006 + FR-022 + FR-030). The grid is
318/// owned and cloned-on-write between steps, so memory peaks at one
319/// extra grid above the input.
320///
321/// SC-012 records the wall-clock linear-scaling guarantee
322/// (`tests/filter_scaling.rs` asserts N=20 ≤ 2.5× N=10). HINT-002
323/// surfaces this bound to library consumers via this rustdoc.
324///
325/// ## Construction
326///
327/// Build programmatically via [`FilterChain::new`] +
328/// [`FilterChain::push`] (per US5), or parse from a `-F` flag string
329/// via [`FilterChain::parse`] (per FR-002). Parsing handles the
330/// `filter1:filter2:...` syntax shared with toilet(1); the CLI
331/// concatenates multiple `-F` flags with `:` before invoking parse.
332///
333/// ```rust
334/// use rusty_figlet::filter::{Filter, FilterChain, RenderGrid};
335///
336/// // Programmatic composition (US5).
337/// let chain = FilterChain::new()
338///     .push(Filter::Crop)
339///     .push(Filter::Border);
340///
341/// // Parsed from a `-F` flag.
342/// let parsed = FilterChain::parse("crop:border").expect("parse");
343/// assert_eq!(chain, parsed);
344///
345/// let grid = RenderGrid::blank(4, 2);
346/// let _ = chain.apply(grid).expect("apply");
347/// ```
348#[derive(Debug, Clone, Default, PartialEq, Eq)]
349pub struct FilterChain {
350    filters: Vec<Filter>,
351}
352
353impl FilterChain {
354    /// Construct an empty chain.
355    ///
356    /// An empty chain applied to a grid returns the input unchanged.
357    #[must_use]
358    pub fn new() -> Self {
359        Self::default()
360    }
361
362    /// Append `filter` to this chain and return the updated chain
363    /// (consuming-self builder per US5 ergonomics).
364    #[must_use]
365    pub fn push(mut self, filter: Filter) -> Self {
366        self.filters.push(filter);
367        self
368    }
369
370    /// Parse a `-F <chain>` specification per FR-002.
371    ///
372    /// Syntax: `filter1:filter2:...` — colon-separated lowercase names.
373    /// Multiple `-F` CLI flags are concatenated with `:` by the caller
374    /// before invoking parse.
375    ///
376    /// Empty segments (`crop::flip`), names longer than 64 bytes, and
377    /// names not in the canonical list (case-sensitive) are all rejected
378    /// with [`FigletError::UnknownFilter`] whose `available` field
379    /// enumerates the 10 valid names in declaration order (per FR-016
380    /// and spec Edge Cases). An entirely empty `spec` parses to an
381    /// empty chain (the no-`-F`-flag case).
382    pub fn parse(spec: &str) -> Result<FilterChain, FigletError> {
383        let mut filters = Vec::new();
384        if spec.is_empty() {
385            return Ok(Self { filters });
386        }
387        for segment in spec.split(':') {
388            if segment.is_empty() || segment.len() > MAX_FILTER_NAME_BYTES {
389                return Err(FigletError::UnknownFilter {
390                    name: segment.to_owned(),
391                    available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
392                });
393            }
394            match Filter::from_name(segment) {
395                Some(f) => filters.push(f),
396                None => {
397                    return Err(FigletError::UnknownFilter {
398                        name: segment.to_owned(),
399                        available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
400                    });
401                }
402            }
403        }
404        Ok(Self { filters })
405    }
406
407    /// Number of filters currently in this chain.
408    #[must_use]
409    pub fn len(&self) -> usize {
410        self.filters.len()
411    }
412
413    /// `true` when this chain has no filters; `apply` would return its
414    /// input unchanged.
415    #[must_use]
416    pub fn is_empty(&self) -> bool {
417        self.filters.is_empty()
418    }
419
420    /// Borrow the chain's filters in application order.
421    #[must_use]
422    pub fn filters(&self) -> &[Filter] {
423        &self.filters
424    }
425
426    /// Apply each filter in order to `grid` and return the resulting
427    /// grid (FR-004).
428    ///
429    /// ## Cost bound (FR-030 + HINT-006 + AD-007)
430    ///
431    /// Runs in `O(n · w · h)` where `n = self.filters().len()`,
432    /// `w = grid.width`, `h = grid.height`. SC-012 enforces a
433    /// wall-clock linear-scaling test on the library so callers can
434    /// rely on this bound when composing long chains programmatically
435    /// (US5).
436    ///
437    /// Empty chains are well-defined — they return the input grid
438    /// unchanged. Filters whose leaf feature is disabled at
439    /// compile-time return a [`FigletError::UnknownFilter`] at
440    /// `apply` time rather than at construction time so existing
441    /// `FilterChain` values keep working when reused across builds
442    /// with different feature surfaces.
443    pub fn apply(&self, grid: RenderGrid) -> Result<RenderGrid, FigletError> {
444        let mut current = grid;
445        for filter in &self.filters {
446            current = dispatch(*filter, current)?;
447        }
448        Ok(current)
449    }
450}
451
452/// Dispatch a single [`Filter`] onto `grid`. Unknown / leaf-disabled
453/// filters return [`FigletError::UnknownFilter`] so `apply` can short-
454/// circuit the chain.
455fn dispatch(filter: Filter, grid: RenderGrid) -> Result<RenderGrid, FigletError> {
456    match filter {
457        Filter::Nothing => Ok(apply_nothing(grid)),
458        #[cfg(feature = "filter-crop")]
459        Filter::Crop => Ok(apply_crop(grid)),
460        #[cfg(not(feature = "filter-crop"))]
461        Filter::Crop => Err(filter_disabled("crop")),
462        #[cfg(feature = "filter-gay")]
463        Filter::Gay => Ok(apply_gay(grid)),
464        #[cfg(not(feature = "filter-gay"))]
465        Filter::Gay => Err(filter_disabled("gay")),
466        #[cfg(feature = "filter-metal")]
467        Filter::Metal => Ok(apply_metal(grid)),
468        #[cfg(not(feature = "filter-metal"))]
469        Filter::Metal => Err(filter_disabled("metal")),
470        #[cfg(feature = "filter-flip")]
471        Filter::Flip => Ok(apply_flip(grid)),
472        #[cfg(not(feature = "filter-flip"))]
473        Filter::Flip => Err(filter_disabled("flip")),
474        #[cfg(feature = "filter-flop")]
475        Filter::Flop => Ok(apply_flop(grid)),
476        #[cfg(not(feature = "filter-flop"))]
477        Filter::Flop => Err(filter_disabled("flop")),
478        #[cfg(feature = "filter-rotate")]
479        Filter::Rotate180 => Ok(apply_rotate180(grid)),
480        #[cfg(not(feature = "filter-rotate"))]
481        Filter::Rotate180 => Err(filter_disabled("rotate180")),
482        #[cfg(feature = "filter-rotate")]
483        Filter::RotateLeft => Ok(apply_rotate_left(grid)),
484        #[cfg(not(feature = "filter-rotate"))]
485        Filter::RotateLeft => Err(filter_disabled("rotateleft")),
486        #[cfg(feature = "filter-rotate")]
487        Filter::RotateRight => Ok(apply_rotate_right(grid)),
488        #[cfg(not(feature = "filter-rotate"))]
489        Filter::RotateRight => Err(filter_disabled("rotateright")),
490        #[cfg(feature = "filter-border")]
491        Filter::Border => Ok(apply_border(grid)),
492        #[cfg(not(feature = "filter-border"))]
493        Filter::Border => Err(filter_disabled("border")),
494    }
495}
496
497#[allow(dead_code)]
498fn filter_disabled(name: &str) -> FigletError {
499    FigletError::UnknownFilter {
500        name: name.to_owned(),
501        available: FILTER_NAMES.iter().map(|&s| s.to_owned()).collect(),
502    }
503}
504
505// ---------------------------------------------------------------------------
506// Filter implementations (AD-002 — pure functions on owned RenderGrids).
507// ---------------------------------------------------------------------------
508
509/// Identity transform — the [`Filter::Nothing`] dispatch target.
510///
511/// Always available (no leaf gate). Returns the input unchanged.
512fn apply_nothing(grid: RenderGrid) -> RenderGrid {
513    grid
514}
515
516/// Trim surrounding all-blank rows and columns (T019, FR-003).
517///
518/// Pure function on the owned grid: clones the retained cells into a
519/// fresh [`RenderGrid`] per AD-002. Returns an empty (0×0) grid when
520/// the input is entirely blank.
521#[cfg(feature = "filter-crop")]
522fn apply_crop(grid: RenderGrid) -> RenderGrid {
523    let h = grid.cells.len();
524    if h == 0 || grid.cells[0].is_empty() {
525        return RenderGrid::empty();
526    }
527    let w = grid.cells[0].len();
528
529    // Locate the bounding box of non-blank cells. None of the four
530    // indices change when every cell is blank — in that case we return
531    // an empty grid.
532    let mut top = h;
533    let mut bottom = 0usize;
534    let mut left = w;
535    let mut right = 0usize;
536
537    for (y, row) in grid.cells.iter().enumerate() {
538        for (x, cell) in row.iter().enumerate() {
539            if !cell.is_blank() {
540                if y < top {
541                    top = y;
542                }
543                if y > bottom {
544                    bottom = y;
545                }
546                if x < left {
547                    left = x;
548                }
549                if x > right {
550                    right = x;
551                }
552            }
553        }
554    }
555
556    if top == h {
557        return RenderGrid::empty();
558    }
559
560    let new_h = bottom - top + 1;
561    let new_w = right - left + 1;
562    let mut cells: Vec<Vec<Cell>> = Vec::with_capacity(new_h);
563    for row in grid.cells.iter().skip(top).take(new_h) {
564        cells.push(row[left..=right].to_vec());
565    }
566    RenderGrid {
567        cells,
568        width: new_w as u32,
569        height: new_h as u32,
570    }
571}
572
573/// Per-column rainbow color sweep (T020, FR-003).
574///
575/// Replaces each cell's foreground color with an HSV-rainbow index
576/// keyed by column. Note: this is the same visual gradient produced by
577/// the v0.2.x `--rainbow` CLI flag (which lives in `src/color.rs`).
578/// Both paths can coexist; `--rainbow` writes SGR escapes directly to
579/// stdout, while `Filter::Gay` rewrites the typed [`Cell::fg`] so
580/// downstream exporters (HTML, SVG, IRC) see the same palette.
581#[cfg(feature = "filter-gay")]
582fn apply_gay(grid: RenderGrid) -> RenderGrid {
583    let w = grid.width.max(1);
584    let mut cells = grid.cells;
585    for row in cells.iter_mut() {
586        for (x, cell) in row.iter_mut().enumerate() {
587            let hue = 360.0_f32 * (x as f32 / w as f32);
588            let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0);
589            cell.fg = Color::Rgb(r, g, b);
590        }
591    }
592    RenderGrid {
593        cells,
594        width: grid.width,
595        height: grid.height,
596    }
597}
598
599/// Blue/gray metallic gradient (T021, FR-003).
600///
601/// Cycles a 4-step Cyan → Blue → BrightCyan → BrightBlue palette down
602/// the rows so the output reads as a vertical metallic sheen. Toilet's
603/// `--metal` filter uses a similar gradient; we derive the row-major
604/// palette from the manpage description without consulting upstream
605/// source.
606#[cfg(feature = "filter-metal")]
607fn apply_metal(grid: RenderGrid) -> RenderGrid {
608    const PALETTE: [NamedColor; 4] = [
609        NamedColor::Cyan,
610        NamedColor::Blue,
611        NamedColor::BrightCyan,
612        NamedColor::BrightBlue,
613    ];
614    let mut cells = grid.cells;
615    for (y, row) in cells.iter_mut().enumerate() {
616        let c = PALETTE[y % PALETTE.len()];
617        for cell in row.iter_mut() {
618            cell.fg = Color::Named(c);
619        }
620    }
621    RenderGrid {
622        cells,
623        width: grid.width,
624        height: grid.height,
625    }
626}
627
628/// Horizontal mirror — reverse each row (T022, FR-003).
629#[cfg(feature = "filter-flip")]
630fn apply_flip(grid: RenderGrid) -> RenderGrid {
631    let mut cells = grid.cells;
632    for row in cells.iter_mut() {
633        row.reverse();
634    }
635    RenderGrid {
636        cells,
637        width: grid.width,
638        height: grid.height,
639    }
640}
641
642/// Vertical mirror — reverse row order (T023, FR-003).
643#[cfg(feature = "filter-flop")]
644fn apply_flop(grid: RenderGrid) -> RenderGrid {
645    let mut cells = grid.cells;
646    cells.reverse();
647    RenderGrid {
648        cells,
649        width: grid.width,
650        height: grid.height,
651    }
652}
653
654/// Rotate 180° — flip + flop (T024, FR-003).
655#[cfg(feature = "filter-rotate")]
656fn apply_rotate180(grid: RenderGrid) -> RenderGrid {
657    let mut cells = grid.cells;
658    cells.reverse();
659    for row in cells.iter_mut() {
660        row.reverse();
661    }
662    RenderGrid {
663        cells,
664        width: grid.width,
665        height: grid.height,
666    }
667}
668
669/// Rotate 90° counter-clockwise — transpose then flop (T025, FR-003).
670///
671/// Output dimensions: `new_width = old_height`, `new_height = old_width`.
672#[cfg(feature = "filter-rotate")]
673fn apply_rotate_left(grid: RenderGrid) -> RenderGrid {
674    let w = grid.width as usize;
675    let h = grid.height as usize;
676    if w == 0 || h == 0 {
677        return RenderGrid::empty();
678    }
679    let mut new_cells: Vec<Vec<Cell>> = (0..w).map(|_| Vec::with_capacity(h)).collect();
680    // For CCW rotation: new[x][h - 1 - y] = old[y][x] → row index of
681    // output is `w - 1 - x_old`, column index is `y_old`. Equivalently,
682    // iterate columns right-to-left, and for each column collect the
683    // entire input column top-to-bottom as the new row.
684    for x in (0..w).rev() {
685        let row: Vec<Cell> = (0..h).map(|y| grid.cells[y][x]).collect();
686        new_cells[w - 1 - x] = row;
687    }
688    RenderGrid {
689        cells: new_cells,
690        width: h as u32,
691        height: w as u32,
692    }
693}
694
695/// Rotate 90° clockwise — transpose then flip (T026, FR-003).
696///
697/// Output dimensions: `new_width = old_height`, `new_height = old_width`.
698#[cfg(feature = "filter-rotate")]
699fn apply_rotate_right(grid: RenderGrid) -> RenderGrid {
700    let w = grid.width as usize;
701    let h = grid.height as usize;
702    if w == 0 || h == 0 {
703        return RenderGrid::empty();
704    }
705    let mut new_cells: Vec<Vec<Cell>> = (0..w).map(|_| Vec::with_capacity(h)).collect();
706    // For CW rotation: new[x][y] is the input column `x` read bottom-to-top.
707    for (x, row_out) in new_cells.iter_mut().enumerate().take(w) {
708        *row_out = (0..h).rev().map(|y| grid.cells[y][x]).collect();
709    }
710    RenderGrid {
711        cells: new_cells,
712        width: h as u32,
713        height: w as u32,
714    }
715}
716
717/// Draw a Unicode box-drawing border around the grid (T027, SC-001).
718///
719/// Adds one row/column of padding on each side, then writes
720/// `┌─...─┐` / `│...│` / `└─...─┘` using single-line Unicode
721/// box-drawing characters (U+2500..U+2518).
722#[cfg(feature = "filter-border")]
723fn apply_border(grid: RenderGrid) -> RenderGrid {
724    let w = grid.width as usize;
725    let h = grid.height as usize;
726    let new_w = w + 2;
727    let new_h = h + 2;
728    let mut cells: Vec<Vec<Cell>> = Vec::with_capacity(new_h);
729
730    // Top border row.
731    let mut top = Vec::with_capacity(new_w);
732    top.push(Cell::new('┌'));
733    for _ in 0..w {
734        top.push(Cell::new('─'));
735    }
736    top.push(Cell::new('┐'));
737    cells.push(top);
738
739    // Interior rows: left │, original cells, right │.
740    for row in grid.cells {
741        let mut new_row = Vec::with_capacity(new_w);
742        new_row.push(Cell::new('│'));
743        new_row.extend(row);
744        new_row.push(Cell::new('│'));
745        cells.push(new_row);
746    }
747
748    // Bottom border row.
749    let mut bottom = Vec::with_capacity(new_w);
750    bottom.push(Cell::new('└'));
751    for _ in 0..w {
752        bottom.push(Cell::new('─'));
753    }
754    bottom.push(Cell::new('┘'));
755    cells.push(bottom);
756
757    RenderGrid {
758        cells,
759        width: new_w as u32,
760        height: new_h as u32,
761    }
762}
763
764/// HSV→RGB conversion shared by [`apply_gay`]. Hue in degrees [0,360),
765/// saturation and value in [0,1]. Mirrors the helper in `src/color.rs`
766/// so the `Filter::Gay` path produces the same palette as the v0.2.x
767/// `--rainbow` flag (see T020 rustdoc).
768#[cfg(feature = "filter-gay")]
769fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
770    let c = v * s;
771    let h_p = (h % 360.0) / 60.0;
772    let x = c * (1.0 - (h_p % 2.0 - 1.0).abs());
773    let m = v - c;
774    let (r1, g1, b1) = match h_p as u32 {
775        0 => (c, x, 0.0),
776        1 => (x, c, 0.0),
777        2 => (0.0, c, x),
778        3 => (0.0, x, c),
779        4 => (x, 0.0, c),
780        _ => (c, 0.0, x),
781    };
782    let to_u8 = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8;
783    (to_u8(r1), to_u8(g1), to_u8(b1))
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    /// AD-011: a Cell's footprint must stay bounded. Exact size depends
791    /// on the target ABI's enum layout; ≤24 bytes is comfortably within
792    /// the ~16-byte design budget for 64-bit targets and absorbs any
793    /// alignment padding the compiler chooses.
794    #[test]
795    fn cell_footprint_is_bounded() {
796        assert!(
797            std::mem::size_of::<Cell>() <= 24,
798            "Cell size {} exceeds AD-011 budget",
799            std::mem::size_of::<Cell>()
800        );
801    }
802
803    #[test]
804    fn parse_empty_chain_is_ok() {
805        let c = FilterChain::parse("").unwrap();
806        assert!(c.is_empty());
807    }
808
809    #[test]
810    fn parse_single_filter() {
811        let c = FilterChain::parse("crop").unwrap();
812        assert_eq!(c.filters(), &[Filter::Crop]);
813    }
814
815    #[test]
816    fn parse_multi_filter_chain() {
817        let c = FilterChain::parse("crop:flip:border").unwrap();
818        assert_eq!(c.filters(), &[Filter::Crop, Filter::Flip, Filter::Border]);
819    }
820
821    #[test]
822    fn parse_empty_segment_is_unknown_filter() {
823        let err = FilterChain::parse("crop::flip").unwrap_err();
824        match err {
825            FigletError::UnknownFilter { name, available } => {
826                assert_eq!(name, "");
827                assert_eq!(available.len(), 10);
828            }
829            other => panic!("expected UnknownFilter, got {other:?}"),
830        }
831    }
832
833    #[test]
834    fn parse_unknown_name_lists_available() {
835        let err = FilterChain::parse("nosuchfilter").unwrap_err();
836        match err {
837            FigletError::UnknownFilter { name, available } => {
838                assert_eq!(name, "nosuchfilter");
839                assert!(available.contains(&"crop".to_string()));
840                assert!(available.contains(&"nothing".to_string()));
841            }
842            other => panic!("expected UnknownFilter, got {other:?}"),
843        }
844    }
845
846    #[test]
847    fn parse_oversize_name_rejected() {
848        let big = "a".repeat(MAX_FILTER_NAME_BYTES + 1);
849        let err = FilterChain::parse(&big).unwrap_err();
850        assert!(matches!(err, FigletError::UnknownFilter { .. }));
851    }
852
853    #[test]
854    fn programmatic_push_matches_parse() {
855        let manual = FilterChain::new().push(Filter::Crop).push(Filter::Flip);
856        let parsed = FilterChain::parse("crop:flip").unwrap();
857        assert_eq!(manual, parsed);
858    }
859
860    #[test]
861    fn empty_chain_apply_is_identity() {
862        let g = RenderGrid::blank(3, 2);
863        let chain = FilterChain::new();
864        let out = chain.apply(g.clone()).unwrap();
865        assert_eq!(out, g);
866    }
867
868    #[test]
869    fn nothing_filter_is_identity() {
870        let g = RenderGrid::blank(3, 2);
871        let chain = FilterChain::new().push(Filter::Nothing);
872        let out = chain.apply(g.clone()).unwrap();
873        assert_eq!(out, g);
874    }
875
876    #[cfg(feature = "filter-crop")]
877    #[test]
878    fn crop_trims_blank_border() {
879        let mut rows = vec![vec![Cell::blank(); 4]; 4];
880        rows[1][1] = Cell::new('X');
881        rows[1][2] = Cell::new('Y');
882        rows[2][1] = Cell::new('Z');
883        let grid = RenderGrid::from_rows(rows);
884        let chain = FilterChain::new().push(Filter::Crop);
885        let out = chain.apply(grid).unwrap();
886        assert_eq!(out.width, 2);
887        assert_eq!(out.height, 2);
888        assert_eq!(out.cells[0][0].ch, 'X');
889        assert_eq!(out.cells[1][0].ch, 'Z');
890    }
891
892    #[cfg(feature = "filter-crop")]
893    #[test]
894    fn crop_all_blank_returns_empty() {
895        let grid = RenderGrid::blank(4, 4);
896        let out = FilterChain::new().push(Filter::Crop).apply(grid).unwrap();
897        assert_eq!(out.width, 0);
898        assert_eq!(out.height, 0);
899    }
900
901    #[cfg(feature = "filter-flip")]
902    #[test]
903    fn flip_reverses_each_row() {
904        let grid = RenderGrid::from_text_rows(&[String::from("ABCD"), String::from("1234")]);
905        let out = FilterChain::new().push(Filter::Flip).apply(grid).unwrap();
906        assert_eq!(out.cells[0][0].ch, 'D');
907        assert_eq!(out.cells[0][3].ch, 'A');
908        assert_eq!(out.cells[1][0].ch, '4');
909    }
910
911    #[cfg(feature = "filter-flop")]
912    #[test]
913    fn flop_reverses_row_order() {
914        let grid = RenderGrid::from_text_rows(&[String::from("AAA"), String::from("BBB")]);
915        let out = FilterChain::new().push(Filter::Flop).apply(grid).unwrap();
916        assert_eq!(out.cells[0][0].ch, 'B');
917        assert_eq!(out.cells[1][0].ch, 'A');
918    }
919
920    #[cfg(feature = "filter-rotate")]
921    #[test]
922    fn rotate180_inverts() {
923        let grid = RenderGrid::from_text_rows(&[String::from("AB"), String::from("CD")]);
924        let out = FilterChain::new()
925            .push(Filter::Rotate180)
926            .apply(grid)
927            .unwrap();
928        assert_eq!(out.cells[0][0].ch, 'D');
929        assert_eq!(out.cells[0][1].ch, 'C');
930        assert_eq!(out.cells[1][0].ch, 'B');
931        assert_eq!(out.cells[1][1].ch, 'A');
932    }
933
934    #[cfg(feature = "filter-rotate")]
935    #[test]
936    fn rotate_left_swaps_dimensions() {
937        let grid = RenderGrid::from_text_rows(&[String::from("ABC"), String::from("DEF")]);
938        let out = FilterChain::new()
939            .push(Filter::RotateLeft)
940            .apply(grid)
941            .unwrap();
942        // Old 3x2 → new 2x3. CCW: top row of output is rightmost column of input,
943        // top-to-bottom: 'C','F'.
944        assert_eq!(out.width, 2);
945        assert_eq!(out.height, 3);
946        assert_eq!(out.cells[0][0].ch, 'C');
947        assert_eq!(out.cells[0][1].ch, 'F');
948        assert_eq!(out.cells[2][0].ch, 'A');
949        assert_eq!(out.cells[2][1].ch, 'D');
950    }
951
952    #[cfg(feature = "filter-rotate")]
953    #[test]
954    fn rotate_right_swaps_dimensions() {
955        let grid = RenderGrid::from_text_rows(&[String::from("ABC"), String::from("DEF")]);
956        let out = FilterChain::new()
957            .push(Filter::RotateRight)
958            .apply(grid)
959            .unwrap();
960        // Old 3x2 → new 2x3. CW: top row of output is leftmost column of input,
961        // bottom-to-top: 'D','A'.
962        assert_eq!(out.width, 2);
963        assert_eq!(out.height, 3);
964        assert_eq!(out.cells[0][0].ch, 'D');
965        assert_eq!(out.cells[0][1].ch, 'A');
966        assert_eq!(out.cells[2][0].ch, 'F');
967        assert_eq!(out.cells[2][1].ch, 'C');
968    }
969
970    #[cfg(feature = "filter-border")]
971    #[test]
972    fn border_adds_one_cell_of_padding() {
973        let grid = RenderGrid::from_text_rows(&[String::from("XX")]);
974        let out = FilterChain::new().push(Filter::Border).apply(grid).unwrap();
975        assert_eq!(out.width, 4);
976        assert_eq!(out.height, 3);
977        assert_eq!(out.cells[0][0].ch, '┌');
978        assert_eq!(out.cells[0][3].ch, '┐');
979        assert_eq!(out.cells[2][0].ch, '└');
980        assert_eq!(out.cells[2][3].ch, '┘');
981        assert_eq!(out.cells[1][1].ch, 'X');
982    }
983
984    #[cfg(feature = "filter-gay")]
985    #[test]
986    fn gay_assigns_rgb_per_column() {
987        let grid = RenderGrid::from_text_rows(&[String::from("ABCD")]);
988        let out = FilterChain::new().push(Filter::Gay).apply(grid).unwrap();
989        // Every cell should now carry an Rgb color. The hue differs per
990        // column so adjacent cells have distinct RGB triples.
991        let c0 = out.cells[0][0].fg;
992        let c1 = out.cells[0][1].fg;
993        assert!(matches!(c0, Color::Rgb(..)));
994        assert!(matches!(c1, Color::Rgb(..)));
995        assert_ne!(c0, c1);
996    }
997
998    #[cfg(feature = "filter-metal")]
999    #[test]
1000    fn metal_cycles_palette_per_row() {
1001        let grid = RenderGrid::from_text_rows(&[
1002            String::from("A"),
1003            String::from("B"),
1004            String::from("C"),
1005            String::from("D"),
1006            String::from("E"),
1007        ]);
1008        let out = FilterChain::new().push(Filter::Metal).apply(grid).unwrap();
1009        // Row 0 and row 4 cycle back to the same palette entry.
1010        assert_eq!(out.cells[0][0].fg, out.cells[4][0].fg);
1011        // Row 0 and row 1 differ.
1012        assert_ne!(out.cells[0][0].fg, out.cells[1][0].fg);
1013    }
1014
1015    #[cfg(all(feature = "filter-flip", feature = "filter-gay"))]
1016    #[test]
1017    fn chain_order_observable_gay_then_flip() {
1018        // AD-009 — filter ordering is observable. gay→flip differs from
1019        // flip→gay because the per-column hue is computed BEFORE the
1020        // horizontal mirror in the first ordering.
1021        let grid = RenderGrid::from_text_rows(&[String::from("ABCD")]);
1022        let a = FilterChain::new()
1023            .push(Filter::Gay)
1024            .push(Filter::Flip)
1025            .apply(grid.clone())
1026            .unwrap();
1027        let b = FilterChain::new()
1028            .push(Filter::Flip)
1029            .push(Filter::Gay)
1030            .apply(grid)
1031            .unwrap();
1032        assert_ne!(a.cells[0][0].fg, b.cells[0][0].fg);
1033    }
1034}