Skip to main content

frankenterm_core/
export.rs

1//! Export adapters for converting terminal grid state to external formats.
2//!
3//! This module defines the API contract, option schemas, and shared
4//! infrastructure for three export paths:
5//!
6//! - **ANSI**: Re-emit grid content as VT/ANSI escape sequences.
7//! - **HTML**: Render grid as styled HTML with span elements.
8//! - **Plain text**: Extract text content without formatting.
9//!
10//! # Architecture
11//!
12//! An [`ExportContext`] bundles borrowed references to the terminal data
13//! sources (grid, scrollback, hyperlink registry). Each export function
14//! takes the context plus format-specific options and returns a `String`.
15//!
16//! The [`ExportRange`] type controls which lines are included in the
17//! output, supporting viewport-only, scrollback-only, full history, or
18//! an arbitrary line range.
19
20use std::fmt::Write;
21use std::ops::Range;
22
23use crate::cell::{Cell, CellFlags, Color, HyperlinkRegistry, SgrFlags};
24use crate::grid::Grid;
25use crate::scrollback::Scrollback;
26
27// ── Shared types ─────────────────────────────────────────────────────
28
29/// Which lines to include in the export.
30///
31/// Line indices in the combined buffer: `0..scrollback.len()` for scrollback
32/// (oldest first), followed by grid viewport rows.
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub enum ExportRange {
35    /// Only the visible viewport (grid rows).
36    #[default]
37    Viewport,
38    /// Only scrollback lines.
39    ScrollbackOnly,
40    /// Both scrollback and viewport (full history).
41    Full,
42    /// A specific line range in the combined buffer.
43    Lines(Range<u32>),
44}
45
46/// Line ending style for exported text.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum LineEnding {
49    /// Unix-style `\n`.
50    #[default]
51    Lf,
52    /// Windows-style `\r\n`.
53    CrLf,
54}
55
56impl LineEnding {
57    /// The string representation of this line ending.
58    #[must_use]
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::Lf => "\n",
62            Self::CrLf => "\r\n",
63        }
64    }
65}
66
67/// Color depth for ANSI export.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum ColorDepth {
70    /// No color output (SGR attributes only: bold, italic, etc.).
71    NoColor,
72    /// 16-color palette (SGR 30–37, 40–47, 90–97, 100–107).
73    Named16,
74    /// 256-color palette (SGR 38;5;N / 48;5;N).
75    Indexed256,
76    /// 24-bit true color (SGR 38;2;R;G;B / 48;2;R;G;B).
77    #[default]
78    TrueColor,
79}
80
81// ── ANSI export options ──────────────────────────────────────────────
82
83/// Options for ANSI escape sequence export.
84///
85/// Controls color depth, line handling, and reset behavior for
86/// re-emitting grid content as VT/ANSI sequences.
87#[derive(Debug, Clone)]
88pub struct AnsiExportOptions {
89    /// Which lines to include.
90    pub range: ExportRange,
91    /// Maximum color depth to emit.
92    pub color_depth: ColorDepth,
93    /// Line ending style.
94    pub line_ending: LineEnding,
95    /// Trim trailing whitespace from each line.
96    pub trim_trailing: bool,
97    /// Emit SGR 0 (reset) at the end of output.
98    pub reset_at_end: bool,
99    /// Join soft-wrapped scrollback lines without inserting a newline.
100    pub join_soft_wraps: bool,
101}
102
103impl Default for AnsiExportOptions {
104    fn default() -> Self {
105        Self {
106            range: ExportRange::Viewport,
107            color_depth: ColorDepth::TrueColor,
108            line_ending: LineEnding::Lf,
109            trim_trailing: true,
110            reset_at_end: true,
111            join_soft_wraps: true,
112        }
113    }
114}
115
116// ── HTML export options ──────────────────────────────────────────────
117
118/// Options for HTML export.
119///
120/// Controls styling mode, hyperlink rendering, and HTML structure
121/// for converting grid content to styled `<pre>` + `<span>` HTML.
122#[derive(Debug, Clone)]
123pub struct HtmlExportOptions {
124    /// Which lines to include.
125    pub range: ExportRange,
126    /// Use inline styles (`true`) or CSS classes (`false`).
127    pub inline_styles: bool,
128    /// CSS class prefix for generated elements.
129    pub class_prefix: String,
130    /// Font family for the wrapper `<pre>` element.
131    pub font_family: String,
132    /// Font size (CSS value) for the wrapper.
133    pub font_size: String,
134    /// Render hyperlinks as `<a>` tags.
135    pub render_hyperlinks: bool,
136    /// Line ending style within the HTML output.
137    pub line_ending: LineEnding,
138    /// Trim trailing whitespace from each line.
139    pub trim_trailing: bool,
140}
141
142impl Default for HtmlExportOptions {
143    fn default() -> Self {
144        Self {
145            range: ExportRange::Viewport,
146            inline_styles: true,
147            class_prefix: "ft".into(),
148            font_family: "monospace".into(),
149            font_size: "14px".into(),
150            render_hyperlinks: true,
151            line_ending: LineEnding::Lf,
152            trim_trailing: true,
153        }
154    }
155}
156
157// ── Text export options ──────────────────────────────────────────────
158
159/// Options for plain text export.
160///
161/// Controls whitespace handling, soft-wrap joining, and combining
162/// mark inclusion for extracting text from grid content.
163#[derive(Debug, Clone)]
164pub struct TextExportOptions {
165    /// Which lines to include.
166    pub range: ExportRange,
167    /// Line ending style.
168    pub line_ending: LineEnding,
169    /// Trim trailing whitespace from each line.
170    pub trim_trailing: bool,
171    /// Join soft-wrapped scrollback lines without inserting a newline.
172    pub join_soft_wraps: bool,
173    /// Include combining marks in output.
174    pub include_combining: bool,
175}
176
177impl Default for TextExportOptions {
178    fn default() -> Self {
179        Self {
180            range: ExportRange::Viewport,
181            line_ending: LineEnding::Lf,
182            trim_trailing: true,
183            join_soft_wraps: true,
184            include_combining: true,
185        }
186    }
187}
188
189// ── Export context ────────────────────────────────────────────────────
190
191/// Bundles borrowed references to terminal data sources for export.
192///
193/// All fields are immutable borrows — the exporter never mutates
194/// terminal state.
195pub struct ExportContext<'a> {
196    /// The visible viewport grid.
197    pub grid: &'a Grid,
198    /// The scrollback buffer.
199    pub scrollback: &'a Scrollback,
200    /// The hyperlink URI registry (for OSC 8 links).
201    pub hyperlinks: &'a HyperlinkRegistry,
202}
203
204impl<'a> ExportContext<'a> {
205    /// Create a new export context.
206    #[must_use]
207    pub fn new(
208        grid: &'a Grid,
209        scrollback: &'a Scrollback,
210        hyperlinks: &'a HyperlinkRegistry,
211    ) -> Self {
212        Self {
213            grid,
214            scrollback,
215            hyperlinks,
216        }
217    }
218}
219
220// ── Shared row resolution ────────────────────────────────────────────
221
222/// A single resolved row for export: cell data plus soft-wrap flag.
223#[derive(Debug, Clone)]
224pub struct ExportRow<'a> {
225    /// The cells of this row.
226    pub cells: &'a [Cell],
227    /// Whether the *next* line continues this one (soft-wrap).
228    /// For viewport rows, this is always `false`.
229    pub is_soft_wrapped: bool,
230}
231
232/// Resolve an [`ExportRange`] into a sequence of rows from the combined
233/// buffer (scrollback + viewport).
234///
235/// The `is_soft_wrapped` flag on each row indicates whether the
236/// *following* line has `wrapped=true`, meaning this row should be
237/// joined with the next without a newline separator.
238#[must_use]
239pub fn resolve_rows<'a>(
240    grid: &'a Grid,
241    scrollback: &'a Scrollback,
242    range: &ExportRange,
243) -> Vec<ExportRow<'a>> {
244    match range {
245        ExportRange::Viewport => (0..grid.rows())
246            .filter_map(|r| {
247                grid.row_cells(r).map(|cells| ExportRow {
248                    cells,
249                    is_soft_wrapped: false,
250                })
251            })
252            .collect(),
253        ExportRange::ScrollbackOnly => resolve_scrollback_rows(scrollback),
254        ExportRange::Full => {
255            let mut rows = resolve_scrollback_rows(scrollback);
256            // The last scrollback row might be soft-wrapped into the
257            // first viewport row — check the first viewport row's
258            // implied "unwrapped" status. We leave scrollback's
259            // is_soft_wrapped as computed (based on next scrollback line).
260            for r in 0..grid.rows() {
261                if let Some(cells) = grid.row_cells(r) {
262                    rows.push(ExportRow {
263                        cells,
264                        is_soft_wrapped: false,
265                    });
266                }
267            }
268            rows
269        }
270        ExportRange::Lines(line_range) => {
271            let sb_len = scrollback.len() as u32;
272            let mut rows = Vec::new();
273            for line_idx in line_range.start..line_range.end {
274                if line_idx < sb_len {
275                    if let Some(sb_line) = scrollback.get(line_idx as usize) {
276                        // Check if the next line is a soft-wrap continuation.
277                        let next_wrapped = scrollback
278                            .get(line_idx as usize + 1)
279                            .is_some_and(|next| next.wrapped);
280                        rows.push(ExportRow {
281                            cells: &sb_line.cells,
282                            is_soft_wrapped: next_wrapped,
283                        });
284                    }
285                } else {
286                    let grid_row = (line_idx - sb_len) as u16;
287                    if let Some(cells) = grid.row_cells(grid_row) {
288                        rows.push(ExportRow {
289                            cells,
290                            is_soft_wrapped: false,
291                        });
292                    }
293                }
294            }
295            rows
296        }
297    }
298}
299
300/// Helper: resolve all scrollback lines with correct soft-wrap flags.
301fn resolve_scrollback_rows(scrollback: &Scrollback) -> Vec<ExportRow<'_>> {
302    let len = scrollback.len();
303    let mut rows = Vec::with_capacity(len);
304    for i in 0..len {
305        let line = scrollback.get(i).unwrap();
306        // A line is soft-wrapped if the *next* line has wrapped=true.
307        let next_wrapped = scrollback.get(i + 1).is_some_and(|next| next.wrapped);
308        rows.push(ExportRow {
309            cells: &line.cells,
310            is_soft_wrapped: next_wrapped,
311        });
312    }
313    rows
314}
315
316// ── Text export (reference implementation) ───────────────────────────
317
318/// Extract text from a single row of cells.
319///
320/// Skips wide-char continuation cells (so wide chars appear once).
321/// Optionally includes combining marks. Optionally trims trailing spaces.
322fn row_cells_to_text(cells: &[Cell], include_combining: bool, trim_trailing: bool) -> String {
323    let mut buf = String::with_capacity(cells.len());
324    for cell in cells {
325        if cell.flags.contains(CellFlags::WIDE_CONTINUATION) {
326            continue;
327        }
328        buf.push(cell.content());
329        if include_combining {
330            for &mark in cell.combining_marks() {
331                buf.push(mark);
332            }
333        }
334    }
335    if trim_trailing {
336        let trimmed_len = buf.trim_end_matches(' ').len();
337        buf.truncate(trimmed_len);
338    }
339    buf
340}
341
342/// Export terminal content as plain text.
343///
344/// Wide-char continuation cells are skipped (each wide char appears once).
345/// Trailing spaces are trimmed per line if configured. Soft-wrapped
346/// scrollback lines are joined without a newline if configured.
347#[must_use]
348pub fn export_text(ctx: &ExportContext<'_>, opts: &TextExportOptions) -> String {
349    let rows = resolve_rows(ctx.grid, ctx.scrollback, &opts.range);
350    if rows.is_empty() {
351        return String::new();
352    }
353
354    let line_end = opts.line_ending.as_str();
355    let mut out = String::new();
356
357    for (i, row) in rows.iter().enumerate() {
358        let text = row_cells_to_text(row.cells, opts.include_combining, opts.trim_trailing);
359        out.push_str(&text);
360
361        // Insert line ending unless:
362        // - This is the last row, OR
363        // - This row is soft-wrapped and join_soft_wraps is enabled.
364        if i + 1 < rows.len() {
365            let skip_newline = opts.join_soft_wraps && row.is_soft_wrapped;
366            if !skip_newline {
367                out.push_str(line_end);
368            }
369        }
370    }
371
372    out
373}
374
375// ── ANSI export (signature — implementation in bd-2vr05.5.2) ─────────
376
377/// Export terminal content as ANSI escape sequences.
378///
379/// Produces a byte-accurate VT/ANSI representation of the grid content
380/// with style transitions (SGR), color sequences, and optional resets.
381///
382/// # Note
383///
384/// Full implementation is tracked in bd-2vr05.5.2. This function
385/// currently delegates to a minimal stub that emits plain text with
386/// SGR reset markers.
387#[must_use]
388pub fn export_ansi(ctx: &ExportContext<'_>, opts: &AnsiExportOptions) -> String {
389    // Minimal stub: emit text with SGR reset at end.
390    // Full ANSI serializer with style transitions in bd-2vr05.5.2.
391    let text_opts = TextExportOptions {
392        range: opts.range.clone(),
393        line_ending: opts.line_ending,
394        trim_trailing: opts.trim_trailing,
395        join_soft_wraps: opts.join_soft_wraps,
396        include_combining: true,
397    };
398    let mut out = export_text(ctx, &text_opts);
399    if opts.reset_at_end {
400        out.push_str("\x1b[0m");
401    }
402    out
403}
404
405/// Format a [`Color`] as an ANSI SGR parameter string for the given layer.
406///
407/// `layer` is `38` for foreground, `48` for background, `58` for underline.
408/// Returns `None` for [`Color::Default`] (caller should emit SGR 39/49/59).
409///
410/// This helper is public so downstream ANSI export implementations
411/// (bd-2vr05.5.2) can reuse it.
412#[must_use]
413pub fn color_to_sgr(color: Color, layer: u8, depth: ColorDepth) -> Option<String> {
414    match depth {
415        ColorDepth::NoColor => None,
416        ColorDepth::Named16 => match color {
417            Color::Default => None,
418            Color::Named(n) if n < 8 => {
419                let base = if layer == 38 { 30 } else { 40 };
420                Some(format!("{}", base + n))
421            }
422            Color::Named(n) if n < 16 => {
423                let base = if layer == 38 { 90 } else { 100 };
424                Some(format!("{}", base + (n - 8)))
425            }
426            // Downgrade indexed/RGB to default in 16-color mode.
427            _ => None,
428        },
429        ColorDepth::Indexed256 => match color {
430            Color::Default => None,
431            Color::Named(n) => Some(format!("{layer};5;{n}")),
432            Color::Indexed(n) => Some(format!("{layer};5;{n}")),
433            Color::Rgb(r, g, b) => {
434                // Approximate RGB to 256-color cube.
435                let idx = rgb_to_256(r, g, b);
436                Some(format!("{layer};5;{idx}"))
437            }
438        },
439        ColorDepth::TrueColor => match color {
440            Color::Default => None,
441            Color::Named(n) => Some(format!("{layer};5;{n}")),
442            Color::Indexed(n) => Some(format!("{layer};5;{n}")),
443            Color::Rgb(r, g, b) => Some(format!("{layer};2;{r};{g};{b}")),
444        },
445    }
446}
447
448/// Approximate an RGB color to the nearest 256-color palette index.
449///
450/// Uses the standard 6x6x6 color cube (indices 16–231) and the
451/// 24-step grayscale ramp (indices 232–255).
452#[must_use]
453pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
454    // Check if it's close to a grayscale value.
455    if r == g && g == b {
456        if r < 8 {
457            return 16; // Closest to black in the cube.
458        }
459        if r > 248 {
460            return 231; // Closest to white in the cube.
461        }
462        // Map to grayscale ramp: 232 + round((r - 8) / 247 * 23).
463        return 232 + (((r as u16 - 8) * 23 + 123) / 247) as u8;
464    }
465    // Map to 6x6x6 color cube.
466    let ri = ((r as u16 * 5 + 127) / 255) as u8;
467    let gi = ((g as u16 * 5 + 127) / 255) as u8;
468    let bi = ((b as u16 * 5 + 127) / 255) as u8;
469    16 + 36 * ri + 6 * gi + bi
470}
471
472/// Format [`SgrFlags`] as a sequence of SGR parameter numbers.
473///
474/// Returns a `Vec` of SGR parameter values that should be included
475/// in a `CSI ... m` sequence. Empty if no flags are set.
476#[must_use]
477pub fn sgr_flags_to_params(flags: SgrFlags) -> Vec<u8> {
478    let mut params = Vec::new();
479    if flags.contains(SgrFlags::BOLD) {
480        params.push(1);
481    }
482    if flags.contains(SgrFlags::DIM) {
483        params.push(2);
484    }
485    if flags.contains(SgrFlags::ITALIC) {
486        params.push(3);
487    }
488    if flags.contains(SgrFlags::UNDERLINE) {
489        params.push(4);
490    }
491    if flags.contains(SgrFlags::BLINK) {
492        params.push(5);
493    }
494    if flags.contains(SgrFlags::INVERSE) {
495        params.push(7);
496    }
497    if flags.contains(SgrFlags::HIDDEN) {
498        params.push(8);
499    }
500    if flags.contains(SgrFlags::STRIKETHROUGH) {
501        params.push(9);
502    }
503    if flags.contains(SgrFlags::DOUBLE_UNDERLINE) {
504        params.push(21);
505    }
506    if flags.contains(SgrFlags::OVERLINE) {
507        params.push(53);
508    }
509    params
510}
511
512// ── HTML export (signature — implementation in bd-2vr05.5.3) ─────────
513
514/// Export terminal content as styled HTML.
515///
516/// Produces a `<pre>` block with `<span>` elements carrying inline CSS
517/// or class attributes. Hyperlinks are rendered as `<a>` tags when
518/// enabled.
519///
520/// # Note
521///
522/// Full implementation is tracked in bd-2vr05.5.3. This function
523/// currently delegates to a minimal stub that emits escaped plain text
524/// wrapped in a `<pre>` element.
525#[must_use]
526pub fn export_html(ctx: &ExportContext<'_>, opts: &HtmlExportOptions) -> String {
527    // Minimal stub: emit escaped plain text in <pre>.
528    // Full HTML serializer with styles/hyperlinks in bd-2vr05.5.3.
529    let text_opts = TextExportOptions {
530        range: opts.range.clone(),
531        line_ending: opts.line_ending,
532        trim_trailing: opts.trim_trailing,
533        join_soft_wraps: false,
534        include_combining: true,
535    };
536    let text = export_text(ctx, &text_opts);
537    let escaped = html_escape(&text);
538
539    let mut out = String::with_capacity(escaped.len() + 200);
540    write!(
541        out,
542        "<pre class=\"{}\" style=\"font-family:{};font-size:{};\">",
543        opts.class_prefix, opts.font_family, opts.font_size,
544    )
545    .unwrap();
546    out.push_str(&escaped);
547    out.push_str("</pre>");
548    out
549}
550
551/// Escape special HTML characters in text.
552///
553/// Public so downstream HTML export implementations (bd-2vr05.5.3) can reuse it.
554#[must_use]
555pub fn html_escape(text: &str) -> String {
556    let mut out = String::with_capacity(text.len());
557    for ch in text.chars() {
558        match ch {
559            '&' => out.push_str("&amp;"),
560            '<' => out.push_str("&lt;"),
561            '>' => out.push_str("&gt;"),
562            '"' => out.push_str("&quot;"),
563            '\'' => out.push_str("&#39;"),
564            _ => out.push(ch),
565        }
566    }
567    out
568}
569
570// ── Tests ────────────────────────────────────────────────────────────
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::cell::{Cell, HyperlinkRegistry, SgrAttrs};
576    use crate::grid::Grid;
577    use crate::scrollback::Scrollback;
578
579    // ── Helpers ──────────────────────────────────────────────────────
580
581    fn make_grid(cols: u16, lines: &[&str]) -> Grid {
582        let rows = lines.len() as u16;
583        let mut g = Grid::new(cols, rows);
584        for (r, text) in lines.iter().enumerate() {
585            for (c, ch) in text.chars().enumerate() {
586                if c >= cols as usize {
587                    break;
588                }
589                g.cell_mut(r as u16, c as u16).unwrap().set_content(ch, 1);
590            }
591        }
592        g
593    }
594
595    fn make_scrollback(lines: &[(&str, bool)]) -> Scrollback {
596        let mut sb = Scrollback::new(64);
597        for (text, wrapped) in lines {
598            let cells: Vec<Cell> = text.chars().map(Cell::new).collect();
599            sb.push_row(&cells, *wrapped);
600        }
601        sb
602    }
603
604    fn default_ctx<'a>(
605        grid: &'a Grid,
606        scrollback: &'a Scrollback,
607        hyperlinks: &'a HyperlinkRegistry,
608    ) -> ExportContext<'a> {
609        ExportContext::new(grid, scrollback, hyperlinks)
610    }
611
612    // ── ExportRange + resolve_rows tests ─────────────────────────────
613
614    #[test]
615    fn resolve_viewport_only() {
616        let grid = make_grid(5, &["hello", "world"]);
617        let sb = Scrollback::new(0);
618        let rows = resolve_rows(&grid, &sb, &ExportRange::Viewport);
619        assert_eq!(rows.len(), 2);
620        assert_eq!(rows[0].cells.len(), 5);
621        assert_eq!(rows[0].cells[0].content(), 'h');
622        assert!(!rows[0].is_soft_wrapped);
623    }
624
625    #[test]
626    fn resolve_scrollback_only() {
627        let grid = Grid::new(5, 1);
628        let sb = make_scrollback(&[("aaa", false), ("bbb", true), ("ccc", false)]);
629        let rows = resolve_rows(&grid, &sb, &ExportRange::ScrollbackOnly);
630        assert_eq!(rows.len(), 3);
631        // "aaa" is soft-wrapped because next line has wrapped=true.
632        assert!(rows[0].is_soft_wrapped);
633        // "bbb" is NOT soft-wrapped because next line has wrapped=false.
634        assert!(!rows[1].is_soft_wrapped);
635        assert!(!rows[2].is_soft_wrapped);
636    }
637
638    #[test]
639    fn resolve_full_includes_both() {
640        let grid = make_grid(5, &["grid"]);
641        let sb = make_scrollback(&[("sb", false)]);
642        let rows = resolve_rows(&grid, &sb, &ExportRange::Full);
643        assert_eq!(rows.len(), 2);
644        assert_eq!(rows[0].cells[0].content(), 's');
645        assert_eq!(rows[1].cells[0].content(), 'g');
646    }
647
648    #[test]
649    fn resolve_lines_range_across_boundary() {
650        let grid = make_grid(5, &["vp0", "vp1"]);
651        let sb = make_scrollback(&[("sb0", false), ("sb1", false)]);
652        // Lines 1..3 = sb1, vp0
653        let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(1..3));
654        assert_eq!(rows.len(), 2);
655        assert_eq!(rows[0].cells[0].content(), 's'); // sb1
656        assert_eq!(rows[1].cells[0].content(), 'v'); // vp0
657    }
658
659    #[test]
660    fn resolve_lines_empty_range() {
661        let grid = make_grid(5, &["x"]);
662        let sb = Scrollback::new(0);
663        let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(0..0));
664        assert!(rows.is_empty());
665    }
666
667    #[test]
668    fn resolve_lines_beyond_bounds() {
669        let grid = make_grid(5, &["x"]);
670        let sb = Scrollback::new(0);
671        let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(5..10));
672        assert!(rows.is_empty());
673    }
674
675    // ── Text export tests ────────────────────────────────────────────
676
677    #[test]
678    fn text_export_viewport_basic() {
679        let grid = make_grid(10, &["hello", "world"]);
680        let sb = Scrollback::new(0);
681        let reg = HyperlinkRegistry::new();
682        let ctx = default_ctx(&grid, &sb, &reg);
683
684        let text = export_text(&ctx, &TextExportOptions::default());
685        assert_eq!(text, "hello\nworld");
686    }
687
688    #[test]
689    fn text_export_trims_trailing_spaces() {
690        let grid = make_grid(10, &["hi"]);
691        let sb = Scrollback::new(0);
692        let reg = HyperlinkRegistry::new();
693        let ctx = default_ctx(&grid, &sb, &reg);
694
695        let text = export_text(&ctx, &TextExportOptions::default());
696        assert_eq!(text, "hi");
697    }
698
699    #[test]
700    fn text_export_no_trim() {
701        let grid = make_grid(5, &["ab"]);
702        let sb = Scrollback::new(0);
703        let reg = HyperlinkRegistry::new();
704        let ctx = default_ctx(&grid, &sb, &reg);
705
706        let opts = TextExportOptions {
707            trim_trailing: false,
708            ..Default::default()
709        };
710        let text = export_text(&ctx, &opts);
711        assert_eq!(text, "ab   "); // 5 cols, "ab" + 3 spaces
712    }
713
714    #[test]
715    fn text_export_crlf_line_endings() {
716        let grid = make_grid(5, &["aa", "bb"]);
717        let sb = Scrollback::new(0);
718        let reg = HyperlinkRegistry::new();
719        let ctx = default_ctx(&grid, &sb, &reg);
720
721        let opts = TextExportOptions {
722            line_ending: LineEnding::CrLf,
723            ..Default::default()
724        };
725        let text = export_text(&ctx, &opts);
726        assert_eq!(text, "aa\r\nbb");
727    }
728
729    #[test]
730    fn text_export_joins_soft_wraps() {
731        let grid = Grid::new(5, 1);
732        let sb = make_scrollback(&[("hello", false), ("world", true)]);
733        let reg = HyperlinkRegistry::new();
734        let ctx = default_ctx(&grid, &sb, &reg);
735
736        let opts = TextExportOptions {
737            range: ExportRange::ScrollbackOnly,
738            join_soft_wraps: true,
739            ..Default::default()
740        };
741        let text = export_text(&ctx, &opts);
742        assert_eq!(text, "helloworld");
743    }
744
745    #[test]
746    fn text_export_no_join_soft_wraps() {
747        let grid = Grid::new(5, 1);
748        let sb = make_scrollback(&[("hello", false), ("world", true)]);
749        let reg = HyperlinkRegistry::new();
750        let ctx = default_ctx(&grid, &sb, &reg);
751
752        let opts = TextExportOptions {
753            range: ExportRange::ScrollbackOnly,
754            join_soft_wraps: false,
755            ..Default::default()
756        };
757        let text = export_text(&ctx, &opts);
758        assert_eq!(text, "hello\nworld");
759    }
760
761    #[test]
762    fn text_export_full_range() {
763        let grid = make_grid(10, &["viewport"]);
764        let sb = make_scrollback(&[("history", false)]);
765        let reg = HyperlinkRegistry::new();
766        let ctx = default_ctx(&grid, &sb, &reg);
767
768        let opts = TextExportOptions {
769            range: ExportRange::Full,
770            ..Default::default()
771        };
772        let text = export_text(&ctx, &opts);
773        assert_eq!(text, "history\nviewport");
774    }
775
776    #[test]
777    fn text_export_empty_grid() {
778        let grid = Grid::new(0, 0);
779        let sb = Scrollback::new(0);
780        let reg = HyperlinkRegistry::new();
781        let ctx = default_ctx(&grid, &sb, &reg);
782
783        let text = export_text(&ctx, &TextExportOptions::default());
784        assert_eq!(text, "");
785    }
786
787    #[test]
788    fn text_export_wide_char_appears_once() {
789        let mut grid = Grid::new(10, 1);
790        let (lead, cont) = Cell::wide('\u{4E2D}', SgrAttrs::default()); // '中'
791        *grid.cell_mut(0, 0).unwrap() = lead;
792        *grid.cell_mut(0, 1).unwrap() = cont;
793        grid.cell_mut(0, 2).unwrap().set_content('x', 1);
794
795        let sb = Scrollback::new(0);
796        let reg = HyperlinkRegistry::new();
797        let ctx = default_ctx(&grid, &sb, &reg);
798
799        let text = export_text(&ctx, &TextExportOptions::default());
800        assert_eq!(text, "中x");
801    }
802
803    #[test]
804    fn text_export_combining_marks() {
805        let mut grid = Grid::new(10, 1);
806        grid.cell_mut(0, 0).unwrap().set_content('e', 1);
807        grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
808
809        let sb = Scrollback::new(0);
810        let reg = HyperlinkRegistry::new();
811        let ctx = default_ctx(&grid, &sb, &reg);
812
813        let opts = TextExportOptions {
814            include_combining: true,
815            ..Default::default()
816        };
817        let text = export_text(&ctx, &opts);
818        assert_eq!(text, "e\u{0301}");
819
820        let opts_no_combining = TextExportOptions {
821            include_combining: false,
822            ..Default::default()
823        };
824        let text = export_text(&ctx, &opts_no_combining);
825        assert_eq!(text, "e");
826    }
827
828    #[test]
829    fn text_export_lines_range() {
830        let grid = make_grid(5, &["vp"]);
831        let sb = make_scrollback(&[("sb0", false), ("sb1", false)]);
832        let reg = HyperlinkRegistry::new();
833        let ctx = default_ctx(&grid, &sb, &reg);
834
835        let opts = TextExportOptions {
836            range: ExportRange::Lines(1..3),
837            ..Default::default()
838        };
839        let text = export_text(&ctx, &opts);
840        assert_eq!(text, "sb1\nvp");
841    }
842
843    // ── ANSI export stub tests ───────────────────────────────────────
844
845    #[test]
846    fn ansi_export_stub_includes_reset() {
847        let grid = make_grid(5, &["hi"]);
848        let sb = Scrollback::new(0);
849        let reg = HyperlinkRegistry::new();
850        let ctx = default_ctx(&grid, &sb, &reg);
851
852        let result = export_ansi(&ctx, &AnsiExportOptions::default());
853        assert!(result.ends_with("\x1b[0m"));
854        assert!(result.contains("hi"));
855    }
856
857    #[test]
858    fn ansi_export_stub_no_reset() {
859        let grid = make_grid(5, &["hi"]);
860        let sb = Scrollback::new(0);
861        let reg = HyperlinkRegistry::new();
862        let ctx = default_ctx(&grid, &sb, &reg);
863
864        let opts = AnsiExportOptions {
865            reset_at_end: false,
866            ..Default::default()
867        };
868        let result = export_ansi(&ctx, &opts);
869        assert!(!result.ends_with("\x1b[0m"));
870    }
871
872    // ── HTML export stub tests ───────────────────────────────────────
873
874    #[test]
875    fn html_export_stub_wraps_in_pre() {
876        let grid = make_grid(5, &["hi"]);
877        let sb = Scrollback::new(0);
878        let reg = HyperlinkRegistry::new();
879        let ctx = default_ctx(&grid, &sb, &reg);
880
881        let result = export_html(&ctx, &HtmlExportOptions::default());
882        assert!(result.starts_with("<pre"));
883        assert!(result.ends_with("</pre>"));
884        assert!(result.contains("hi"));
885    }
886
887    #[test]
888    fn html_export_escapes_special_chars() {
889        let grid = make_grid(20, &["<b>test</b> & \"ok\""]);
890        let sb = Scrollback::new(0);
891        let reg = HyperlinkRegistry::new();
892        let ctx = default_ctx(&grid, &sb, &reg);
893
894        let result = export_html(&ctx, &HtmlExportOptions::default());
895        assert!(result.contains("&lt;b&gt;"));
896        assert!(result.contains("&amp;"));
897        assert!(result.contains("&quot;"));
898    }
899
900    // ── Helper function tests ────────────────────────────────────────
901
902    #[test]
903    fn html_escape_special_chars() {
904        assert_eq!(html_escape("<>&\"'"), "&lt;&gt;&amp;&quot;&#39;");
905        assert_eq!(html_escape("hello"), "hello");
906        assert_eq!(html_escape(""), "");
907    }
908
909    #[test]
910    fn color_to_sgr_truecolor() {
911        assert_eq!(
912            color_to_sgr(Color::Rgb(255, 0, 128), 38, ColorDepth::TrueColor),
913            Some("38;2;255;0;128".into())
914        );
915        assert_eq!(
916            color_to_sgr(Color::Named(1), 38, ColorDepth::TrueColor),
917            Some("38;5;1".into())
918        );
919        assert_eq!(
920            color_to_sgr(Color::Indexed(200), 48, ColorDepth::TrueColor),
921            Some("48;5;200".into())
922        );
923        assert_eq!(
924            color_to_sgr(Color::Default, 38, ColorDepth::TrueColor),
925            None
926        );
927    }
928
929    #[test]
930    fn color_to_sgr_named16() {
931        assert_eq!(
932            color_to_sgr(Color::Named(1), 38, ColorDepth::Named16),
933            Some("31".into()) // 30 + 1
934        );
935        assert_eq!(
936            color_to_sgr(Color::Named(9), 38, ColorDepth::Named16),
937            Some("91".into()) // 90 + (9-8)
938        );
939        assert_eq!(
940            color_to_sgr(Color::Named(0), 48, ColorDepth::Named16),
941            Some("40".into())
942        );
943        // RGB downgraded to None in 16-color mode.
944        assert_eq!(
945            color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::Named16),
946            None
947        );
948    }
949
950    #[test]
951    fn color_to_sgr_no_color() {
952        assert_eq!(
953            color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::NoColor),
954            None
955        );
956        assert_eq!(color_to_sgr(Color::Named(1), 38, ColorDepth::NoColor), None);
957    }
958
959    #[test]
960    fn color_to_sgr_indexed256() {
961        assert_eq!(
962            color_to_sgr(Color::Indexed(42), 38, ColorDepth::Indexed256),
963            Some("38;5;42".into())
964        );
965        // RGB approximated to 256 palette.
966        let result = color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::Indexed256);
967        assert!(result.is_some());
968        assert!(result.unwrap().starts_with("38;5;"));
969    }
970
971    #[test]
972    fn rgb_to_256_grayscale() {
973        // Pure black.
974        assert_eq!(rgb_to_256(0, 0, 0), 16);
975        // Pure white.
976        assert_eq!(rgb_to_256(255, 255, 255), 231);
977        // Mid-gray should be in the grayscale ramp.
978        let idx = rgb_to_256(128, 128, 128);
979        assert!(idx >= 232);
980    }
981
982    #[test]
983    fn rgb_to_256_color_cube() {
984        // Pure red should map to the red corner of the 6x6x6 cube.
985        let idx = rgb_to_256(255, 0, 0);
986        assert_eq!(idx, 16 + 36 * 5); // 196
987    }
988
989    #[test]
990    fn sgr_flags_to_params_empty() {
991        assert!(sgr_flags_to_params(SgrFlags::empty()).is_empty());
992    }
993
994    #[test]
995    fn sgr_flags_to_params_all_flags() {
996        let flags = SgrFlags::BOLD
997            | SgrFlags::DIM
998            | SgrFlags::ITALIC
999            | SgrFlags::UNDERLINE
1000            | SgrFlags::BLINK
1001            | SgrFlags::INVERSE
1002            | SgrFlags::HIDDEN
1003            | SgrFlags::STRIKETHROUGH
1004            | SgrFlags::DOUBLE_UNDERLINE
1005            | SgrFlags::OVERLINE;
1006        let params = sgr_flags_to_params(flags);
1007        assert_eq!(params, vec![1, 2, 3, 4, 5, 7, 8, 9, 21, 53]);
1008    }
1009
1010    #[test]
1011    fn sgr_flags_to_params_single() {
1012        assert_eq!(sgr_flags_to_params(SgrFlags::BOLD), vec![1]);
1013        assert_eq!(sgr_flags_to_params(SgrFlags::ITALIC), vec![3]);
1014        assert_eq!(sgr_flags_to_params(SgrFlags::STRIKETHROUGH), vec![9]);
1015    }
1016
1017    // ── LineEnding tests ─────────────────────────────────────────────
1018
1019    #[test]
1020    fn line_ending_as_str() {
1021        assert_eq!(LineEnding::Lf.as_str(), "\n");
1022        assert_eq!(LineEnding::CrLf.as_str(), "\r\n");
1023    }
1024
1025    #[test]
1026    fn line_ending_default_is_lf() {
1027        assert_eq!(LineEnding::default(), LineEnding::Lf);
1028    }
1029
1030    // ── Default option tests ─────────────────────────────────────────
1031
1032    #[test]
1033    fn ansi_options_default() {
1034        let opts = AnsiExportOptions::default();
1035        assert_eq!(opts.range, ExportRange::Viewport);
1036        assert_eq!(opts.color_depth, ColorDepth::TrueColor);
1037        assert!(opts.trim_trailing);
1038        assert!(opts.reset_at_end);
1039        assert!(opts.join_soft_wraps);
1040    }
1041
1042    #[test]
1043    fn html_options_default() {
1044        let opts = HtmlExportOptions::default();
1045        assert_eq!(opts.range, ExportRange::Viewport);
1046        assert!(opts.inline_styles);
1047        assert_eq!(opts.class_prefix, "ft");
1048        assert!(opts.render_hyperlinks);
1049        assert!(opts.trim_trailing);
1050    }
1051
1052    #[test]
1053    fn text_options_default() {
1054        let opts = TextExportOptions::default();
1055        assert_eq!(opts.range, ExportRange::Viewport);
1056        assert!(opts.trim_trailing);
1057        assert!(opts.join_soft_wraps);
1058        assert!(opts.include_combining);
1059    }
1060
1061    // ── Determinism test ─────────────────────────────────────────────
1062
1063    #[test]
1064    fn text_export_is_deterministic() {
1065        let grid = make_grid(10, &["hello", "world"]);
1066        let sb = make_scrollback(&[("history", false)]);
1067        let reg = HyperlinkRegistry::new();
1068        let ctx = default_ctx(&grid, &sb, &reg);
1069
1070        let opts = TextExportOptions {
1071            range: ExportRange::Full,
1072            ..Default::default()
1073        };
1074
1075        let a = export_text(&ctx, &opts);
1076        let b = export_text(&ctx, &opts);
1077        assert_eq!(a, b, "export_text must be deterministic for fixed inputs");
1078    }
1079
1080    #[test]
1081    fn row_cells_to_text_basic() {
1082        let cells: Vec<Cell> = "hello".chars().map(Cell::new).collect();
1083        assert_eq!(row_cells_to_text(&cells, true, true), "hello");
1084    }
1085
1086    #[test]
1087    fn row_cells_to_text_trims_trailing() {
1088        let mut cells: Vec<Cell> = "hi".chars().map(Cell::new).collect();
1089        cells.push(Cell::default()); // space
1090        cells.push(Cell::default()); // space
1091        assert_eq!(row_cells_to_text(&cells, true, true), "hi");
1092        assert_eq!(row_cells_to_text(&cells, true, false), "hi  ");
1093    }
1094
1095    #[test]
1096    fn row_cells_to_text_wide_char() {
1097        let (lead, cont) = Cell::wide('中', SgrAttrs::default());
1098        let mut cells = vec![lead, cont];
1099        cells.push(Cell::new('x'));
1100        assert_eq!(row_cells_to_text(&cells, true, true), "中x");
1101    }
1102
1103    #[test]
1104    fn row_cells_to_text_combining() {
1105        let mut cell = Cell::new('e');
1106        cell.push_combining('\u{0301}');
1107        let cells = vec![cell];
1108        assert_eq!(row_cells_to_text(&cells, true, true), "e\u{0301}");
1109        assert_eq!(row_cells_to_text(&cells, false, true), "e");
1110    }
1111}