markdown-tui-explorer 1.7.0

A terminal-based markdown file browser and viewer with search, syntax highlighting, and live reload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use image::{DynamicImage, RgbaImage};
use ratatui_image::{picker::Picker, protocol::StatefulProtocol};
use resvg::usvg;

use crate::config::MermaidMode;
use crate::markdown::MermaidBlockId;

/// Maximum number of concurrent mermaid render tasks.  Each task runs
/// `mermaid_rs_renderer::render()` + resvg rasterization on a blocking
/// thread — both CPU-intensive.  Without a cap, opening a doc with many
/// diagrams after a theme change (which clears the cache) would spawn
/// one thread per diagram and saturate every core.
const MAX_CONCURRENT_RENDERS: u32 = 2;

/// Timeout for a single mermaid render.  `mermaid-rs-renderer` is
/// pre-1.0 and can hang on certain diagram types; without a timeout the
/// blocking thread runs forever at 100% CPU.
const RENDER_TIMEOUT: Duration = Duration::from_secs(30);

/// Global counter of in-flight mermaid render tasks.
static IN_FLIGHT: AtomicU32 = AtomicU32::new(0);

/// System fonts are loaded once on first use and reused for every diagram.
/// Without this, resvg rasterizes shapes but cannot render any text in the SVG.
fn font_db() -> &'static Arc<usvg::fontdb::Database> {
    static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
    DB.get_or_init(|| {
        let mut db = usvg::fontdb::Database::new();
        db.load_system_fonts();
        Arc::new(db)
    })
}

/// Minimum mermaid block height in display lines, even for tiny diagrams.
pub const MIN_MERMAID_HEIGHT: u32 = 8;

/// Maximum mermaid block height used in the test suite as an explicit cap.
///
/// Production code uses the user-configurable value from
/// `Config::mermaid_max_height` instead of this constant.
#[allow(dead_code)]
pub const MAX_MERMAID_HEIGHT: u32 = 50;

/// Fallback height used when the cache has no entry for a diagram yet
/// (before any rendering has been kicked off).
pub const DEFAULT_MERMAID_HEIGHT: u32 = 20;

/// The state of a mermaid diagram in the render cache.
pub enum MermaidEntry {
    /// Background task has been spawned; image is not yet available.
    Pending,
    /// Image is ready and encoded for the detected graphics protocol.
    ///
    /// Boxed because `StatefulProtocol` is large (>256 bytes), and clippy
    /// warns about large enum variants that inflate every instance of the enum.
    Ready {
        protocol: Box<StatefulProtocol>,
        /// Height of the rendered image in terminal cells, clamped to
        /// `[MIN_MERMAID_HEIGHT, MAX_MERMAID_HEIGHT]`.
        cell_height: u32,
    },
    /// Rendering failed; display the source with this short error message.
    Failed(String),
    /// Graphics are disabled (e.g. inside tmux); display the source with a hint.
    SourceOnly(String),
    /// Graphics are unavailable but the diagram was successfully rendered
    /// to Unicode box-drawing characters via `figurehead`.  The `String`
    /// contains the ready-to-display ASCII/Unicode art.
    AsciiDiagram {
        /// The rendered diagram text.
        diagram: String,
        /// Short reason why graphics aren't available (shown in the footer).
        reason: String,
    },
}

/// Rendering configuration passed to [`MermaidCache::ensure_queued`].
///
/// Grouping these parameters avoids tripping the `clippy::too_many_arguments`
/// lint while keeping the call site readable.
pub struct MermaidRenderConfig<'a> {
    /// Terminal graphics picker; `None` when graphics are disabled.
    pub picker: Option<&'a ratatui_image::picker::Picker>,
    /// Action channel used to deliver completed render results.
    pub action_tx: &'a tokio::sync::mpsc::UnboundedSender<crate::action::Action>,
    /// Whether the process is running inside a tmux session.
    pub in_tmux: bool,
    /// Background colour used to recolour the rendered SVG.
    pub bg_rgb: (u8, u8, u8),
    /// User-configured rendering mode.
    pub mode: MermaidMode,
    /// User-configured maximum height in display lines.
    pub max_height: u32,
}

/// Per-app cache mapping diagram ids to their render state.
pub struct MermaidCache {
    entries: HashMap<MermaidBlockId, MermaidEntry>,
}

impl MermaidCache {
    /// Create an empty cache with no entries.
    pub fn new() -> Self {
        Self {
            entries: HashMap::new(),
        }
    }

    /// Return a shared reference to the entry for `id`, if any.
    pub fn get(&self, id: MermaidBlockId) -> Option<&MermaidEntry> {
        self.entries.get(&id)
    }

    /// Return a mutable reference to the entry for `id`, if any.
    pub fn get_mut(&mut self, id: MermaidBlockId) -> Option<&mut MermaidEntry> {
        self.entries.get_mut(&id)
    }

    /// Insert a new entry, overwriting any existing one.
    pub fn insert(&mut self, id: MermaidBlockId, entry: MermaidEntry) {
        self.entries.insert(id, entry);
    }

    /// Return the display-line height for `id` based on its current cache state.
    ///
    /// # Arguments
    ///
    /// * `id`         – diagram identifier.
    /// * `source`     – raw mermaid source (used to measure fallback text height).
    /// * `max_height` – user-configured upper bound (from `Config::mermaid_max_height`).
    ///
    /// # Behaviour
    ///
    /// - `Ready`: the stored `cell_height` derived from the rendered image.
    /// - `Pending`: `MIN_MERMAID_HEIGHT` (small placeholder until rendering finishes).
    /// - `Failed` / `SourceOnly`: source-line count clamped to `[MIN, max_height]`.
    /// - `AsciiDiagram`: diagram-line count clamped to `[MIN, max_height]`.
    /// - Not present: `DEFAULT_MERMAID_HEIGHT`.
    pub fn height(&self, id: MermaidBlockId, source: &str, max_height: u32) -> u32 {
        match self.entries.get(&id) {
            None => DEFAULT_MERMAID_HEIGHT,
            Some(MermaidEntry::Pending) => MIN_MERMAID_HEIGHT,
            Some(MermaidEntry::Ready { cell_height, .. }) => *cell_height,
            Some(MermaidEntry::Failed(_) | MermaidEntry::SourceOnly(_)) => {
                let source_lines = crate::cast::u32_sat(source.lines().count()) + 2;
                source_lines.clamp(MIN_MERMAID_HEIGHT, max_height)
            }
            Some(MermaidEntry::AsciiDiagram { diagram, .. }) => {
                let diagram_lines = crate::cast::u32_sat(diagram.lines().count()) + 2;
                diagram_lines.clamp(MIN_MERMAID_HEIGHT, max_height)
            }
        }
    }

    /// Remove all cached entries.
    pub fn clear(&mut self) {
        self.entries.clear();
    }

    /// Ensure `id` has an entry. If it already has one, do nothing and return
    /// `false`. If not, create an entry (and possibly spawn a background task),
    /// then return `true` only when a new background image task was spawned.
    ///
    /// # Decision tree
    ///
    /// 1. **`Text` mode** — always use figurehead; never spawn image tasks.
    /// 2. **`has_limited_rendering` types** (e.g. `stateDiagram`) in `Auto` mode —
    ///    try figurehead first; fall back to `SourceOnly` on figurehead error.
    ///    The image pipeline is skipped because mermaid-rs-renderer renders
    ///    these types poorly.
    /// 3. **No graphics** (`picker` is `None`) in `Auto` mode — try figurehead,
    ///    then `SourceOnly`.
    /// 4. **`Image` mode with no graphics** — insert `SourceOnly`; figurehead is
    ///    not tried (the caller explicitly opted out of text fallbacks).
    /// 5. **Graphics available** (`Auto` or `Image` mode) — spawn image render.
    ///
    /// # Arguments
    ///
    /// * `id`     – stable diagram identifier.
    /// * `source` – raw mermaid source text.
    /// * `cfg`    – rendering configuration (mode, picker, max_height, etc.).
    pub fn ensure_queued(
        &mut self,
        id: MermaidBlockId,
        source: &str,
        cfg: &MermaidRenderConfig<'_>,
    ) -> bool {
        if self.entries.contains_key(&id) {
            return false;
        }

        // ── Text mode: always figurehead, never spawn image tasks ────────────
        if cfg.mode == MermaidMode::Text {
            let entry = match try_text_render(source) {
                Ok(diagram) => MermaidEntry::AsciiDiagram {
                    diagram,
                    reason: "text mode".to_string(),
                },
                Err(_) => {
                    MermaidEntry::SourceOnly("figurehead render failed, showing source".to_string())
                }
            };
            self.entries.insert(id, entry);
            return false;
        }

        // ── Diagram types with limited image-render support ──────────────────
        // In Auto mode we still try figurehead so state diagrams render as
        // Unicode box-drawing art rather than raw source.
        // In Image mode we skip figurehead entirely (caller opted out).
        if has_limited_rendering(source) {
            let entry = if cfg.mode == MermaidMode::Image {
                // Image-only: skip figurehead, show raw source.
                MermaidEntry::SourceOnly(
                    "diagram type not supported by image renderer, showing source".to_string(),
                )
            } else {
                // Auto mode: try figurehead first.
                match try_text_render(source) {
                    Ok(diagram) => MermaidEntry::AsciiDiagram {
                        diagram,
                        reason: "diagram type uses text-mode rendering".to_string(),
                    },
                    Err(_) => MermaidEntry::SourceOnly(
                        "diagram type has limited rendering, showing source".to_string(),
                    ),
                }
            };
            self.entries.insert(id, entry);
            return false;
        }

        // ── No graphics available ────────────────────────────────────────────
        let Some(picker) = cfg.picker else {
            let reason = if cfg.in_tmux {
                TMUX_DISABLED_REASON.to_string()
            } else {
                "graphics unavailable".to_string()
            };

            let entry = if cfg.mode == MermaidMode::Image {
                // Image-only mode: don't try figurehead, just show source.
                MermaidEntry::SourceOnly(reason)
            } else {
                // Auto mode: try text-mode rendering via figurehead before
                // falling back to raw source.  This gives terminals without
                // graphics protocol support a readable Unicode box-drawing diagram.
                match try_text_render(source) {
                    Ok(diagram) => MermaidEntry::AsciiDiagram { diagram, reason },
                    Err(_) => MermaidEntry::SourceOnly(reason),
                }
            };
            self.entries.insert(id, entry);
            return false;
        };

        // ── Graphics available: spawn image render task ──────────────────────
        // Limit concurrent renders to avoid saturating every CPU core when
        // many diagrams are queued (e.g. after a theme change clears the cache).
        if IN_FLIGHT.load(Ordering::Relaxed) >= MAX_CONCURRENT_RENDERS {
            // Don't insert Pending — the block stays un-cached and will be
            // retried on the next draw frame when a slot frees up.
            return false;
        }
        IN_FLIGHT.fetch_add(1, Ordering::Relaxed);

        self.entries.insert(id, MermaidEntry::Pending);

        let source = source.to_string();
        let picker = picker.clone();
        let tx = cfg.action_tx.clone();
        let bg_rgb = cfg.bg_rgb;
        let max_height = cfg.max_height;

        tokio::task::spawn_blocking(move || {
            // Run the actual render in a sub-thread with a timeout so a
            // hung mermaid-rs-renderer doesn't peg the CPU forever.
            let result = render_with_timeout(&source, &picker, bg_rgb, max_height);
            IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
            let entry = match result {
                Ok((protocol, cell_height)) => MermaidEntry::Ready {
                    protocol: Box::new(protocol),
                    cell_height,
                },
                Err(e) => MermaidEntry::Failed(e),
            };
            let _ = tx.send(crate::action::Action::MermaidReady(id, Box::new(entry)));
        });

        true
    }

    /// Drop all entries whose id is not in `alive`.
    ///
    /// Call after a live reload so stale entries from superseded diagrams don't
    /// accumulate in the cache indefinitely.
    pub fn retain(&mut self, alive: &std::collections::HashSet<MermaidBlockId>) {
        self.entries.retain(|id, _| alive.contains(id));
    }
}

/// Wrapper that runs [`render_blocking`] inside a sub-thread with a
/// [`RENDER_TIMEOUT`] deadline.  If the mermaid parser hangs (known
/// pre-1.0 issue), the sub-thread is detached and the caller gets a
/// clean error instead of a permanently pegged CPU core.
fn render_with_timeout(
    source: &str,
    picker: &Picker,
    bg_rgb: (u8, u8, u8),
    max_height: u32,
) -> Result<(StatefulProtocol, u32), String> {
    let (tx, rx) = std::sync::mpsc::channel();
    let source = source.to_string();
    let picker = picker.clone();

    std::thread::spawn(move || {
        let result = render_blocking(&source, &picker, bg_rgb, max_height);
        let _ = tx.send(result);
    });

    rx.recv_timeout(RENDER_TIMEOUT).map_err(|_| {
        format!(
            "mermaid render timed out after {}s — diagram may trigger a parser bug",
            RENDER_TIMEOUT.as_secs()
        )
    })?
}

/// CPU-bound: render mermaid source → SVG → `DynamicImage` → `StatefulProtocol`.
///
/// Returns the protocol and the image's height in terminal cells, clamped to
/// `[MIN_MERMAID_HEIGHT, max_height]`.
///
/// # Arguments
///
/// * `source`     – raw mermaid source text.
/// * `picker`     – terminal graphics picker.
/// * `bg_rgb`     – background colour used to recolour the rendered SVG.
/// * `max_height` – upper bound in display lines (from `Config::mermaid_max_height`).
fn render_blocking(
    source: &str,
    picker: &Picker,
    bg_rgb: (u8, u8, u8),
    max_height: u32,
) -> Result<(StatefulProtocol, u32), String> {
    let svg = mermaid_rs_renderer::render(source).map_err(|e| format!("render error: {e}"))?;

    let img = svg_to_image(&svg, bg_rgb).map_err(|e| format!("svg rasterize: {e}"))?;

    let cell_height = compute_cell_height(&img, picker, max_height);
    Ok((picker.new_resize_protocol(img), cell_height))
}

/// Compute the natural height of `img` in terminal cells using the picker's
/// reported font size. Clamped to `[MIN_MERMAID_HEIGHT, max_height]`.
///
/// # Arguments
///
/// * `img`        – the rasterised diagram image.
/// * `picker`     – terminal graphics picker (provides font cell pixel size).
/// * `max_height` – upper bound in display lines (from `Config::mermaid_max_height`).
fn compute_cell_height(img: &DynamicImage, picker: &Picker, max_height: u32) -> u32 {
    let (_, cell_px_h) = picker.font_size();
    let px_h = img.height();
    if cell_px_h == 0 {
        return DEFAULT_MERMAID_HEIGHT;
    }
    let cells = px_h.div_ceil(u32::from(cell_px_h));
    cells.clamp(MIN_MERMAID_HEIGHT, max_height)
}

/// Multiplier applied to the SVG's intrinsic size when rasterizing. Mermaid's
/// default SVG dimensions are small (a few hundred pixels), and ratatui-image's
/// `Resize::Fit` preserves aspect without upscaling, so without this the image
/// only fills a fraction of the viewer. SVG is vector so there is no quality
/// loss; the extra pixels are downscaled to the rect as needed.
const SVG_RENDER_SCALE: f32 = 3.0;

/// Rasterize an SVG string to a `DynamicImage`, recoloring the SVG's default
/// light palette to match the active theme. For dark themes (average luminance
/// < 128), node fills, text, borders, and arrows are remapped to dark-friendly
/// equivalents. The canvas background is always replaced with `bg_rgb`.
fn svg_to_image(svg: &str, bg_rgb: (u8, u8, u8)) -> Result<DynamicImage, String> {
    let bg_hex = format!("#{:02X}{:02X}{:02X}", bg_rgb.0, bg_rgb.1, bg_rgb.2);
    let svg = svg.replacen("fill=\"#FFFFFF\"", &format!("fill=\"{bg_hex}\""), 1);

    let is_dark = (u16::from(bg_rgb.0) + u16::from(bg_rgb.1) + u16::from(bg_rgb.2)) / 3 < 128;
    let svg = if is_dark {
        svg.replace("fill=\"#F8FAFC\"", "fill=\"#1e293b\"")
            .replace("stroke=\"#94A3B8\"", "stroke=\"#64748b\"")
            .replace("fill=\"#0F172A\"", "fill=\"#e2e8f0\"")
            .replace("fill=\"#64748B\"", "fill=\"#94a3b8\"")
            .replace("stroke=\"#64748B\"", "stroke=\"#94a3b8\"")
    } else {
        svg
    };

    let opts = usvg::Options {
        fontdb: Arc::clone(font_db()),
        ..usvg::Options::default()
    };
    let tree = usvg::Tree::from_str(&svg, &opts).map_err(|e| format!("usvg parse: {e}"))?;

    let size = tree.size();
    // SVG dimensions are always non-negative and bounded in practice; `.ceil()` followed
    // by clamping to u32 is intentional — suppress pedantic cast warnings here.
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let width = (size.width() * SVG_RENDER_SCALE).ceil() as u32;
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let height = (size.height() * SVG_RENDER_SCALE).ceil() as u32;
    if width == 0 || height == 0 {
        return Err("empty SVG dimensions".to_string());
    }

    let mut pixmap =
        resvg::tiny_skia::Pixmap::new(width, height).ok_or("failed to allocate pixmap")?;

    resvg::render(
        &tree,
        resvg::tiny_skia::Transform::from_scale(SVG_RENDER_SCALE, SVG_RENDER_SCALE),
        &mut pixmap.as_mut(),
    );

    // tiny_skia's pixmap is RGBA premultiplied; image::RgbaImage is RGBA
    // straight-alpha. Demultiply each pixel.
    let raw = pixmap.take();
    let rgba = demultiply_alpha(&raw, width, height)?;
    Ok(DynamicImage::ImageRgba8(rgba))
}

fn demultiply_alpha(data: &[u8], width: u32, height: u32) -> Result<RgbaImage, String> {
    let mut out = Vec::with_capacity(data.len());
    for pixel in data.chunks_exact(4) {
        let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
        if a == 0 {
            out.extend_from_slice(&[0, 0, 0, 0]);
        } else {
            // f32 arithmetic for premultiplied-alpha demultiplication; values are
            // always in [0.0, 255.0] after `.min(255.0)`, so the cast to u8 is safe.
            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
            {
                let factor = 255.0 / f32::from(a);
                out.push((f32::from(r) * factor).min(255.0) as u8);
                out.push((f32::from(g) * factor).min(255.0) as u8);
                out.push((f32::from(b) * factor).min(255.0) as u8);
            }
            out.push(a);
        }
    }
    RgbaImage::from_raw(width, height, out).ok_or("image buffer size mismatch".to_string())
}

/// Create a [`Picker`] by querying the terminal, or return `None` on failure.
///
/// Returns `None` when inside tmux (detected via the `$TMUX` environment
/// variable) because tmux's multiplexing layer corrupts terminal graphics
/// escape sequences.
pub fn create_picker() -> Option<Picker> {
    if std::env::var("TMUX").is_ok() {
        return None;
    }

    // `from_query_stdio` sends escape sequences to query font-size and graphics
    // protocol support. Fall back to halfblocks if the query fails or the
    // terminal doesn't respond in time.
    match Picker::from_query_stdio() {
        Ok(picker) => Some(picker),
        Err(_) => Some(Picker::halfblocks()),
    }
}

/// The reason graphics are unavailable in a tmux session.
pub const TMUX_DISABLED_REASON: &str = "disable tmux for graphics";

/// Try to render mermaid source to Unicode box-drawing text.
///
/// Currently returns `Err` unconditionally.  The only candidate crate
/// (`figurehead 0.4.3`) has three blocking issues for TUI use:
///
/// 1. Bare `println!()` calls in production code that corrupt raw-mode
///    terminals (stdout writes bypass ratatui).
/// 2. Panic on certain sequence diagrams (slice-index out of bounds).
/// 3. Potential infinite loops on complex inputs (freezes the draw loop
///    since it runs synchronously on the main thread).
///
/// The `MermaidMode::Text` setting and `AsciiDiagram` cache variant are
/// kept as infrastructure for when a production-ready text renderer
/// becomes available.
fn try_text_render(_source: &str) -> Result<String, String> {
    Err("text-mode mermaid rendering is not yet available — no stable renderer crate exists".to_string())
}

/// Public wrapper around [`try_text_render`] for use from the
/// `MermaidReady` action handler when an image render fails.
pub fn try_text_render_public(source: &str) -> Result<String, String> {
    try_text_render(source)
}

fn has_limited_rendering(source: &str) -> bool {
    let t = source.trim_start();
    t.starts_with("stateDiagram")
}

#[cfg(test)]
mod tests {
    use super::*;

    const SEQUENCE_DIAGRAM: &str = r"sequenceDiagram
    participant W as Worker
    participant CP as CheckpointStore
    participant ES as EventReader
    W->>CP: Read checkpoint (last sequence)
    CP-->>W: sequence_number
    W->>ES: Poll events (after sequence, limit 500)
    ES-->>W: batch of StoredEvents";

    const GRAPH_LR_1: &str = r"graph LR
    subgraph Supervisor
        direction TB
        F[Factory] -->|creates| W[Worker]
        W -->|panics/exits| F
    end
    W -->|beat every cycle| HB[Heartbeat]
    HB -->|checked every 10s| WD[Watchdog]
    WD -->|stall > 120s| CT[Cancel Token]
    CT -->|stops| W
    style WD fill:#c82,stroke:#fff,color:#fff";

    const STATE_DIAGRAM: &str = r"stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN : 5 consecutive failures
    OPEN --> HALF_OPEN : probe interval elapsed
    HALF_OPEN --> CLOSED : probe succeeds
    HALF_OPEN --> OPEN : probe fails (increased backoff)";

    const GRAPH_LR_2: &str = r"graph LR
    subgraph projections-pg [projections-pg :9092]
        PG_W[event_log, account_registry]
    end
    PG_W --> PG[(PostgreSQL)]
    style PG fill:#336,stroke:#fff,color:#fff";

    #[test]
    fn render_four_target_diagrams() {
        let diagrams = [
            ("sequenceDiagram", SEQUENCE_DIAGRAM),
            ("graph LR (resilience)", GRAPH_LR_1),
            ("stateDiagram-v2", STATE_DIAGRAM),
            ("graph LR (deployments)", GRAPH_LR_2),
        ];

        let mut ready_count = 0;
        let mut failed: Vec<(&str, String)> = Vec::new();

        for (name, src) in &diagrams {
            match mermaid_rs_renderer::render(src) {
                Ok(svg) => match svg_to_image(&svg, (255, 255, 255)) {
                    Ok(_) => {
                        ready_count += 1;
                    }
                    Err(e) => failed.push((name, format!("rasterize: {e}"))),
                },
                Err(e) => failed.push((name, format!("mermaid: {e}"))),
            }
        }

        // CI must have at least 2 of 4 succeed to pass.
        assert!(
            ready_count >= 2,
            "only {ready_count}/4 diagrams rendered successfully; failures: {failed:?}"
        );
    }

    /// A helper used by tests that need to call `ensure_queued` without a real
    /// tokio runtime. The channel is created but never polled; we only care about
    /// the resulting cache entry, not whether actions are delivered.
    fn make_tx() -> tokio::sync::mpsc::UnboundedSender<crate::action::Action> {
        tokio::sync::mpsc::unbounded_channel().0
    }

    #[test]
    fn cache_height_no_entry_returns_default() {
        let cache = MermaidCache::new();
        let id = MermaidBlockId(1);
        assert_eq!(
            cache.height(id, "graph LR\n    A --> B", MAX_MERMAID_HEIGHT),
            DEFAULT_MERMAID_HEIGHT
        );
    }

    #[test]
    fn cache_height_pending_returns_min() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(2);
        cache.insert(id, MermaidEntry::Pending);
        assert_eq!(cache.height(id, "", MAX_MERMAID_HEIGHT), MIN_MERMAID_HEIGHT);
    }

    #[test]
    fn cache_height_ready_returns_cell_height() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(3);
        cache.insert(
            id,
            MermaidEntry::Ready {
                protocol: Box::new(
                    ratatui_image::picker::Picker::halfblocks()
                        .new_resize_protocol(image::DynamicImage::new_rgba8(10, 10)),
                ),
                cell_height: 15,
            },
        );
        assert_eq!(cache.height(id, "", MAX_MERMAID_HEIGHT), 15);
    }

    #[test]
    fn cache_height_failed_clamps_to_range() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(4);
        cache.insert(id, MermaidEntry::Failed("err".to_string()));
        let h = cache.height(id, "line1\nline2\nline3", MAX_MERMAID_HEIGHT);
        assert!((MIN_MERMAID_HEIGHT..=MAX_MERMAID_HEIGHT).contains(&h));
    }

    #[test]
    fn cache_height_source_only_clamps_to_range() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(5);
        cache.insert(id, MermaidEntry::SourceOnly("tmux".to_string()));
        let mut source = String::new();
        for i in 0..100usize {
            source.push_str("line");
            source.push_str(&i.to_string());
            source.push('\n');
        }
        let h = cache.height(id, &source, MAX_MERMAID_HEIGHT);
        assert_eq!(h, MAX_MERMAID_HEIGHT);
    }

    #[test]
    fn cache_retain_drops_stale_entries() {
        let mut cache = MermaidCache::new();
        let id1 = MermaidBlockId(10);
        let id2 = MermaidBlockId(20);
        let id3 = MermaidBlockId(30);
        cache.insert(id1, MermaidEntry::Pending);
        cache.insert(id2, MermaidEntry::Pending);
        cache.insert(id3, MermaidEntry::Pending);

        let mut alive = std::collections::HashSet::new();
        alive.insert(id1);
        alive.insert(id3);
        cache.retain(&alive);

        assert!(cache.get(id1).is_some());
        assert!(cache.get(id2).is_none());
        assert!(cache.get(id3).is_some());
    }

    /// In `Auto` mode, a `stateDiagram-v2` source must produce an
    /// `AsciiDiagram` entry — figurehead handles state diagrams and the
    /// image pipeline is skipped for them.
    #[test]
    fn limited_rendering_tries_figurehead_first() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(100);
        let src = "stateDiagram-v2\n[*] --> A\nA --> B";
        let tx = make_tx();

        let cfg = MermaidRenderConfig {
            picker: None,
            action_tx: &tx,
            in_tmux: false,
            bg_rgb: (0, 0, 0),
            mode: MermaidMode::Auto,
            max_height: 30,
        };
        cache.ensure_queued(id, src, &cfg);

        let entry = cache.get(id).expect("entry must be present");
        assert!(
            matches!(entry, MermaidEntry::SourceOnly(_)),
            "expected SourceOnly (text renderer is stubbed)"
        );
    }

    /// In `Text` mode, a flowchart must not spawn an image task and
    /// must produce an `AsciiDiagram` via figurehead.
    #[test]
    fn text_mode_never_spawns_image_task() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(101);
        let src = "graph LR\n    A --> B";
        let tx = make_tx();

        let picker = ratatui_image::picker::Picker::halfblocks();
        let cfg = MermaidRenderConfig {
            picker: Some(&picker),
            action_tx: &tx,
            in_tmux: false,
            bg_rgb: (0, 0, 0),
            mode: MermaidMode::Text,
            max_height: 30,
        };
        let spawned = cache.ensure_queued(id, src, &cfg);

        assert!(!spawned, "Text mode must never spawn an image task");
        let entry = cache.get(id).expect("entry must be present");
        assert!(
            matches!(entry, MermaidEntry::SourceOnly(_)),
            "expected SourceOnly (text renderer is stubbed)"
        );
    }

    /// In `Image` mode with no picker, the entry must be `SourceOnly` — figurehead
    /// is not tried because the caller opted out of text fallbacks.
    #[test]
    fn image_mode_skips_figurehead() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(102);
        let src = "graph LR\n    A --> B";
        let tx = make_tx();

        let cfg = MermaidRenderConfig {
            picker: None,
            action_tx: &tx,
            in_tmux: false,
            bg_rgb: (0, 0, 0),
            mode: MermaidMode::Image,
            max_height: 30,
        };
        cache.ensure_queued(id, src, &cfg);

        let entry = cache.get(id).expect("entry must be present");
        assert!(
            matches!(entry, MermaidEntry::SourceOnly(_)),
            "expected SourceOnly in Image mode with no graphics, got a different variant"
        );
    }

    #[test]
    fn height_respects_custom_max_height() {
        let mut cache = MermaidCache::new();
        let id = MermaidBlockId(200);
        // 50 source lines + 2 = 52; should be clamped to 25.
        let source: String = (0..50).map(|i| format!("line{i}\n")).collect();
        cache.insert(id, MermaidEntry::SourceOnly("x".to_string()));
        let h = cache.height(id, &source, 25);
        assert_eq!(h, 25, "height must be clamped to the supplied max_height");
    }
}