Skip to main content

rich_cli/
lib.rs

1//! `rich` — a Rust port of the `rich-cli` terminal toolbox.
2//!
3//! The command line lives in this library target so the Python wheel can run
4//! it in-process ([`run`] and [`run_embedded`] are its only public items);
5//! `main.rs` is the `rich` binary and only calls [`run`].
6//!
7//! This binary mirrors the upstream `rich-cli` command-line tool and is built on
8//! the [`rich`] library crate. It implements the rendering modes `--print`,
9//! `--markdown`, `--rst`, `--json`, `--syntax`, `--csv`, `--ipynb`, and `--rule`, plus
10//! width/justify, HTML/SVG export to a file (`--export-html PATH` / `-o PATH`,
11//! `--export-svg PATH`, which may be combined), the
12//! `--panel`/`--padding` decorators (with `--title`/`--caption`/`--style`), a
13//! plain-file printer (with extension auto-detection), **URL fetch** (`rich <url>`,
14//! behind the default `fetch` feature), **paging** (`--pager`), and a capability
15//! demo — i.e. the whole common rich-cli surface.
16//!
17//! On top of that it has documented binary-boundary conveniences that upstream
18//! lacks (see `docs/PORTING.md`): JSONL/log streaming, images and GIFs (behind
19//! the default `art` feature), `--watch`, `--batch`, config profiles and
20//! `--theme-file`, and the tool commands `inspect`, `diff` (images, text and
21//! patches), `view`, `hex`, `unicode`, `env`, `capture`, `ansi explain`,
22//! `doctor`, `bench compare`, `completions`, `docs`, `config` and `plugins`;
23//! the diagram sources `mermaid`, `dot`, `deps` and `schema`, `chart` and
24//! `profile`;
25//! the interactive commands (`choose`, `filter`, `input`, `confirm`, `pager`,
26//! `write`, `file`, `color`, `asset`, `explore`); `micro`; `record`; and
27//! `serve` (behind the off-by-default `serve` feature). Each composes public
28//! `rich` / `rich-ext` / `rich-art` / `rich-diagram` / `rich-mermaid` /
29//! `rich-interact` / `rich-micro` / `rich-record` / `rich-web` APIs.
30
31use std::io::{BufRead, IsTerminal, Read, Write};
32use std::path::{Path, PathBuf};
33use std::process::ExitCode;
34
35mod authoring;
36mod batch;
37mod batch_output;
38mod batch_paths;
39#[cfg(test)]
40mod batch_races;
41mod chart;
42mod cli_spec;
43mod config;
44mod controls;
45mod demo;
46mod doctor;
47mod inspect;
48#[cfg(feature = "interact")]
49mod interactive;
50#[cfg(feature = "art")]
51mod micro;
52mod plugins;
53mod profile;
54#[cfg(feature = "record")]
55mod record;
56mod render_target;
57#[cfg(feature = "serve")]
58mod serve;
59mod sources;
60mod structured_log;
61mod tools;
62mod viewers;
63mod watch;
64use batch::run_batch;
65use config::{config_args, ConfigRoots};
66
67use rich::cells::cell_len;
68use rich::markdown::Markdown;
69use rich::measure::Measurement;
70use rich::protocol::{LineRenderable, OwnedTableRows};
71use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
72use rich::text::Text;
73use rich::{
74    filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
75    Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
76    LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
77    Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
78    DEFAULT_TERMINAL_THEME,
79};
80use rich_data::csv::{has_header, read_rows, sniff, Dialect};
81use rich_data::infer::{InferredType, Inferrer};
82use rich_ext::cli::CliExtensions;
83use rich_ext::encoding::{has_utf16_bom, Encoding};
84use rich_ext::sanitize_terminal_controls;
85use rich_ext::ConsoleExt;
86
87/// Boxed `Text` helper to cut down on `Box::new(Text::new(...))` noise.
88fn text(content: &str) -> Box<dyn Renderable> {
89    Box::new(Text::new(content))
90}
91
92const VERSION: &str = env!("CARGO_PKG_VERSION");
93
94/// How to render the resource. Mirrors the mutually-exclusive rich-cli flags.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum Mode {
97    /// No explicit flag: auto-detect by file extension, else print plain.
98    Auto,
99    /// `-p/--print`: interpret the resource as console markup.
100    Print,
101    /// `-m/--markdown`: render the resource as Markdown.
102    Markdown,
103    /// `-j/--json`: pretty-print the resource as JSON.
104    Json,
105    /// `-x/--syntax`: syntax-highlight the resource (language from extension).
106    Syntax,
107    /// `--csv`: render a CSV/TSV resource as a table.
108    Csv,
109    /// `.ipynb`: render a Jupyter notebook (markdown + code cells + outputs).
110    Ipynb,
111    /// `--rst`: render reStructuredText, as upstream's `rich-rst` does.
112    Rst,
113    /// `--gif`: animate one or more GIFs in place.
114    Gif,
115    /// `--diff`: perceptually compare two images (takes exactly two resources).
116    Diff,
117    /// `--image`: render a single still image as ASCII art, half-blocks, or Sixel.
118    Image,
119    /// `--rule`: draw a horizontal rule (the resource, if any, is its title).
120    Rule,
121    /// `--jsonl`: stream JSON Lines / NDJSON records.
122    JsonLines,
123    /// `--log`: stream common structured-log JSONL records.
124    Log,
125    /// `--inspect`: explore structured data (not upstream; see `inspect.rs`).
126    Inspect,
127    /// `ansi explain` / `--ansi-explain`: decode escape sequences (`tools.rs`).
128    AnsiExplain,
129    /// `view`: detect the format, then show it paged, numbered and searchable
130    /// (not upstream; see `viewers.rs`).
131    View,
132    /// `hex`: a hex dump with an ASCII panel (`viewers.rs`).
133    Hex,
134    /// `unicode`: graphemes, code points, widths and invalid UTF-8.
135    Unicode,
136    /// `env`: environment variables, redacted, or one PATH-like variable.
137    Env,
138    /// `capture -- CMD…`: run a command and show or export its output.
139    Capture,
140    /// `mermaid`: draw a Mermaid diagram (not upstream; see `rs-rich-mermaid`).
141    Mermaid,
142    /// `dot`: draw a DOT (Graphviz) graph natively (`sources.rs`).
143    Dot,
144    /// `deps`: a Cargo dependency tree from `cargo metadata` (`sources.rs`).
145    Deps,
146    /// `schema`: a JSON Schema, SQL DDL or Arrow schema as a tree, two
147    /// compared, or an ER diagram (`sources.rs`).
148    Schema,
149    /// `chart`: a chart from CSV, JSON or stdin (`chart.rs`).
150    Chart,
151    /// `profile`: a profile of CSV, TSV or JSON Lines (`profile.rs`).
152    Profile,
153}
154
155impl Mode {
156    fn streams_records(self) -> bool {
157        matches!(self, Self::JsonLines | Self::Log)
158    }
159
160    fn draws_directly(self) -> bool {
161        matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
162    }
163
164    fn allows_extensions(self) -> bool {
165        !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
166            && !self.streams_records()
167    }
168
169    fn accepts_multiple_resources(self) -> bool {
170        matches!(
171            self,
172            Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
173        )
174    }
175}
176
177struct ModeSpec {
178    mode: Mode,
179    primary: &'static str,
180    aliases: &'static [&'static str],
181}
182
183const MODE_SPECS: &[ModeSpec] = &[
184    ModeSpec {
185        mode: Mode::Auto,
186        primary: "auto",
187        aliases: &[],
188    },
189    ModeSpec {
190        mode: Mode::Print,
191        primary: "print",
192        aliases: &["print"],
193    },
194    ModeSpec {
195        mode: Mode::Markdown,
196        primary: "markdown",
197        aliases: &["markdown", "md"],
198    },
199    ModeSpec {
200        mode: Mode::Json,
201        primary: "json",
202        aliases: &["json"],
203    },
204    ModeSpec {
205        mode: Mode::Syntax,
206        primary: "syntax",
207        aliases: &["syntax", "code"],
208    },
209    ModeSpec {
210        mode: Mode::Csv,
211        primary: "csv",
212        aliases: &["csv", "tsv"],
213    },
214    ModeSpec {
215        mode: Mode::Ipynb,
216        primary: "ipynb",
217        aliases: &["ipynb", "notebook"],
218    },
219    ModeSpec {
220        mode: Mode::Rst,
221        primary: "rst",
222        aliases: &["rst"],
223    },
224    ModeSpec {
225        mode: Mode::Gif,
226        primary: "gif",
227        aliases: &["gif"],
228    },
229    ModeSpec {
230        mode: Mode::Diff,
231        primary: "diff",
232        aliases: &["diff"],
233    },
234    ModeSpec {
235        mode: Mode::Image,
236        primary: "image",
237        aliases: &["image"],
238    },
239    ModeSpec {
240        mode: Mode::Rule,
241        primary: "rule",
242        aliases: &["rule"],
243    },
244    ModeSpec {
245        mode: Mode::JsonLines,
246        primary: "jsonl",
247        aliases: &["jsonl", "ndjson"],
248    },
249    ModeSpec {
250        mode: Mode::Log,
251        primary: "log",
252        aliases: &["log", "logs"],
253    },
254    ModeSpec {
255        mode: Mode::Inspect,
256        primary: "inspect",
257        aliases: &["inspect"],
258    },
259    ModeSpec {
260        mode: Mode::AnsiExplain,
261        primary: "ansi",
262        aliases: &["ansi", "ansi-explain"],
263    },
264    ModeSpec {
265        mode: Mode::View,
266        primary: "view",
267        aliases: &["view"],
268    },
269    ModeSpec {
270        mode: Mode::Hex,
271        primary: "hex",
272        aliases: &["hex", "hexdump"],
273    },
274    ModeSpec {
275        mode: Mode::Unicode,
276        primary: "unicode",
277        aliases: &["unicode"],
278    },
279    ModeSpec {
280        mode: Mode::Env,
281        primary: "env",
282        aliases: &["env"],
283    },
284    ModeSpec {
285        mode: Mode::Capture,
286        primary: "capture",
287        aliases: &["capture"],
288    },
289    ModeSpec {
290        mode: Mode::Mermaid,
291        primary: "mermaid",
292        aliases: &["mermaid", "mmd"],
293    },
294    ModeSpec {
295        mode: Mode::Dot,
296        primary: "dot",
297        aliases: &["dot", "graphviz"],
298    },
299    ModeSpec {
300        mode: Mode::Deps,
301        primary: "deps",
302        aliases: &["deps"],
303    },
304    ModeSpec {
305        mode: Mode::Schema,
306        primary: "schema",
307        aliases: &["schema"],
308    },
309    ModeSpec {
310        mode: Mode::Chart,
311        primary: "chart",
312        aliases: &["chart"],
313    },
314    ModeSpec {
315        mode: Mode::Profile,
316        primary: "profile",
317        aliases: &["profile"],
318    },
319];
320
321const RENDER_MODE_FLAGS: &str =
322    "--print/--markdown/--rst/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325enum ReportFormat {
326    Human,
327    Json,
328}
329
330impl ReportFormat {
331    fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
332        match option {
333            "--report" => {
334                *self = match value.ok_or("--report requires one of: human, json")? {
335                    "human" => Self::Human,
336                    "json" => Self::Json,
337                    other => return Err(format!("unknown report format {other:?} (human, json)")),
338                };
339            }
340            "--machine-json" => *self = Self::Json,
341            _ => {}
342        }
343        Ok(())
344    }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum ExitClass {
349    Success,
350    Usage,
351    Input,
352    Data,
353    #[cfg_attr(not(feature = "art"), allow(dead_code))]
354    Gate,
355}
356
357impl ExitClass {
358    fn code(self) -> u8 {
359        match self {
360            Self::Success => 0,
361            Self::Usage => 2,
362            Self::Input => 3,
363            Self::Data => 4,
364            Self::Gate => 5,
365        }
366    }
367
368    fn name(self) -> &'static str {
369        match self {
370            Self::Success => "success",
371            Self::Usage => "usage",
372            Self::Input => "input",
373            Self::Data => "data",
374            Self::Gate => "gate",
375        }
376    }
377
378    fn exit_code(self) -> ExitCode {
379        ExitCode::from(self.code())
380    }
381}
382
383/// How `--diff` draws the image part of its report.
384///
385/// Detection of terminal graphics support is a heuristic and will be wrong
386/// somewhere (there is no reliable probe that works when output is piped), so
387/// this is exposed rather than inferred: when the guess is wrong the user
388/// picks.
389#[derive(Clone, Copy, PartialEq, Eq, Debug)]
390enum ImageMode {
391    /// Sixel where it looks supported, else half-blocks, else ASCII.
392    Auto,
393    /// Real pixels via the Sixel graphics protocol.
394    Sixel,
395    /// Half-block characters — works in any truecolour terminal.
396    Blocks,
397    /// Unicode Braille cells.
398    Braille,
399    /// Unicode quadrant blocks: 2×2 pixels, two colours per cell.
400    Quadrants,
401    /// A character ramp, the jp2a-style rendering. No colour required.
402    Ascii,
403    /// Skip the picture; print only the numbers.
404    None,
405}
406
407impl std::str::FromStr for ImageMode {
408    type Err = String;
409
410    fn from_str(value: &str) -> Result<Self, Self::Err> {
411        match value.to_ascii_lowercase().as_str() {
412            "auto" => Ok(Self::Auto),
413            "sixel" => Ok(Self::Sixel),
414            "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
415            "braille" => Ok(Self::Braille),
416            "quadrants" | "quadrant" => Ok(Self::Quadrants),
417            "ascii" | "art" => Ok(Self::Ascii),
418            "none" | "off" => Ok(Self::None),
419            other => Err(format!(
420                "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
421            )),
422        }
423    }
424}
425
426/// Upstream's options for source files: `Syntax` for `--syntax` (and
427/// automatic mode, which upstream renders as syntax), and the code cells of
428/// `--ipynb`; `--head`/`--tail` also cut `--csv` rows.
429#[derive(Clone, Debug, Default, PartialEq)]
430struct SourceOptions {
431    /// `--head/-h LINES`, `--tail/-t LINES` (at least 1).
432    head: Option<usize>,
433    tail: Option<usize>,
434    /// `-n/--line-numbers`.
435    line_numbers: bool,
436    /// `-g/--guides`: indentation guides.
437    guides: bool,
438    /// `--lexer LEXER`: the language to highlight as.
439    lexer: Option<String>,
440    /// `--no-wrap`: crop long source lines, and don't wrap `--print` text.
441    no_wrap: bool,
442    /// `--infer`: a `--csv` table's column types, inferred from its cells
443    /// (`rich_data::infer`), under each heading and aligning the numbers.
444    /// Not upstream; without it the table is rich-cli 1.8.1's.
445    infer: bool,
446}
447
448impl SourceOptions {
449    /// Whether any option asks for a `Syntax` view.
450    fn wants_syntax(&self) -> bool {
451        self.head.is_some()
452            || self.tail.is_some()
453            || self.line_numbers
454            || self.guides
455            || self.lexer.is_some()
456            || self.no_wrap
457    }
458
459    /// Upstream's `_line_range(head, tail, num_lines)`, for `Syntax`'s
460    /// `line_range`. `--tail N` keeps upstream's arithmetic,
461    /// `(num_lines - tail + 2, num_lines + 1)`, which shows the last `N - 1`
462    /// lines: `rich-cli --tail 3` prints two.
463    fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
464        let lines = python_line_count(code) as i64;
465        match (self.head, self.tail) {
466            (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
467            (Some(head), None) => Ok(Some((1, head as i64))),
468            (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
469            (None, None) => Ok(None),
470        }
471    }
472
473    /// `Syntax` as upstream's CLI builds it.
474    fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
475        let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
476            .line_numbers(self.line_numbers)
477            .indent_guides(self.guides)
478            .word_wrap(!self.no_wrap);
479        if let Some((start, end)) = self.line_range(code)? {
480            syntax = syntax.line_range(Some(start), Some(end));
481        }
482        Ok(syntax)
483    }
484}
485
486/// `len(text.splitlines())`, which upstream's `num_lines` is: Python also
487/// ends a line at `\r`, `\v`, `\f`, `\x1c`–`\x1e`, `\x85`, U+2028 and
488/// U+2029, where `str::lines` ends one only at `\n`.
489fn python_line_count(text: &str) -> usize {
490    let mut count = 0;
491    let mut chars = text.chars().peekable();
492    let mut open = false;
493    while let Some(c) = chars.next() {
494        match c {
495            '\r' => {
496                if chars.peek() == Some(&'\n') {
497                    chars.next();
498                }
499                count += 1;
500                open = false;
501            }
502            '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
503            | '\u{2029}' => {
504                count += 1;
505                open = false;
506            }
507            _ => open = true,
508        }
509    }
510    count + usize::from(open)
511}
512
513/// Parsed command line.
514#[derive(Clone)]
515struct Cli {
516    mode: Mode,
517    resource: Option<String>,
518    /// Every positional argument. Only `--gif` accepts more than one (so
519    /// several animations can share the screen); other modes reject extras.
520    /// Read only by the GIF player, hence unused without the `art` feature.
521    #[cfg_attr(not(feature = "art"), allow(dead_code))]
522    resources: Vec<String>,
523    /// `--loop N`: how many times `--gif` repeats (0 = forever).
524    #[cfg_attr(not(feature = "art"), allow(dead_code))]
525    loops: Option<usize>,
526    /// `--threshold PCT`: with `--diff`, exit non-zero when the changed
527    /// percentage of the canvas exceeds this. Makes the tool a CI gate.
528    #[cfg_attr(not(feature = "art"), allow(dead_code))]
529    diff_threshold: Option<f32>,
530    /// `--image-mode`: how `--diff`/`--image` draws its picture.
531    #[cfg_attr(not(feature = "art"), allow(dead_code))]
532    image_mode: ImageMode,
533    #[cfg_attr(not(feature = "art"), allow(dead_code))]
534    image_fit: Option<String>,
535    #[cfg_attr(not(feature = "art"), allow(dead_code))]
536    image_anchor: Option<String>,
537    #[cfg_attr(not(feature = "art"), allow(dead_code))]
538    image_background: Option<ImageBackdrop>,
539    #[cfg_attr(not(feature = "art"), allow(dead_code))]
540    image_color: Option<String>,
541    #[cfg_attr(not(feature = "art"), allow(dead_code))]
542    image_dither: Option<String>,
543    #[cfg_attr(not(feature = "art"), allow(dead_code))]
544    image_color_distance: Option<String>,
545    #[cfg_attr(not(feature = "art"), allow(dead_code))]
546    image_rotate: Option<u16>,
547    #[cfg_attr(not(feature = "art"), allow(dead_code))]
548    image_flip_horizontal: bool,
549    #[cfg_attr(not(feature = "art"), allow(dead_code))]
550    image_flip_vertical: bool,
551    #[cfg_attr(not(feature = "art"), allow(dead_code))]
552    image_grayscale: bool,
553    #[cfg_attr(not(feature = "art"), allow(dead_code))]
554    image_max_width: Option<usize>,
555    #[cfg_attr(not(feature = "art"), allow(dead_code))]
556    image_max_height: Option<usize>,
557    /// `--image-brightness`, `--image-contrast`, `--image-gamma` (1.0 = unchanged).
558    #[cfg_attr(not(feature = "art"), allow(dead_code))]
559    image_brightness: Option<f32>,
560    #[cfg_attr(not(feature = "art"), allow(dead_code))]
561    image_contrast: Option<f32>,
562    #[cfg_attr(not(feature = "art"), allow(dead_code))]
563    image_gamma: Option<f32>,
564    log_presentation: String,
565    /// `--mermaid-backend`: how Mermaid diagrams are drawn; `None` when not
566    /// given (see [`MermaidBackend`]).
567    mermaid_backend: Option<MermaidBackend>,
568    /// `--dot-backend`: how DOT graphs are drawn and exported; `None` when
569    /// not given (see [`sources::DotBackend`]).
570    dot_backend: Option<sources::DotBackend>,
571    /// `rich deps` options: `--metadata`, `--why`, `--graph`, `--depth`, …
572    graph_sources: sources::GraphSourceOptions,
573    /// `rich chart` options: `--kind`, `--x`, `--y`.
574    chart: chart::ChartOptions,
575    /// `rich schema` options: `--er`.
576    schema: sources::SchemaOptions,
577    /// `rich profile` options: `--sample`, `--columns`, `--top`.
578    profile: profile::ProfileOptions,
579    /// `--highlighter NAME`: the console-wide code highlighter.
580    highlighter: Option<String>,
581    /// `--code-theme NAME`: a theme of the chosen code highlighter.
582    code_theme: Option<String>,
583    /// `--plugin PATH` (and a trusted config's `plugins`): runtime plugins to
584    /// load, in order.
585    plugins: Vec<String>,
586    /// `--micro-project` (and a trusted config's `micro_project`): load the
587    /// project's `.rich/micro/` micro assets.
588    #[cfg_attr(not(feature = "art"), allow(dead_code))]
589    micro_project: bool,
590    theme_styles: std::collections::BTreeMap<String, Style>,
591    /// `--theme-file PATH`: styles from an upstream `[styles]` theme file,
592    /// layered under config themes and `--theme-style`.
593    theme_file_styles: std::collections::BTreeMap<String, Style>,
594    /// `--height N`: with `--image`, render this many rows instead of the
595    /// backend's default.
596    #[cfg_attr(not(feature = "art"), allow(dead_code))]
597    height: Option<usize>,
598    extensions: CliExtensions,
599    width: Option<usize>,
600    justify: Option<Justify>,
601    /// `-L`/`-R`/`-C`/`-F` (`--text-left`, …): the justify of `--print` and
602    /// `--rule` text, as distinct from `--left`/`--right`/`--center`, which
603    /// align the whole output.
604    text_justify: Option<Justify>,
605    /// `--emoji`/`-j`… upstream's `-j`: replace `:emoji:` codes. Upstream's
606    /// console has `emoji=False` unless given.
607    emoji: bool,
608    /// `--soft`: print with `soft_wrap=True`.
609    soft: bool,
610    /// `-W/--max-width`: `console.print(width=…)`.
611    max_width: Option<usize>,
612    /// `--rule-style`, `--rule-char`: the `--rule` line.
613    rule_style: Option<Style>,
614    rule_char: Option<String>,
615    /// `--force-terminal`: `Console(force_terminal=True)`.
616    force_terminal: bool,
617    /// `--head`/`--tail`, `-n`, `-g`, `--lexer`, `--no-wrap`.
618    source: SourceOptions,
619    no_color: bool,
620    /// `--export-html PATH` (`-o PATH`): also write a self-contained HTML
621    /// document to PATH. The resource is still rendered to the terminal.
622    export_html: Option<String>,
623    /// `--export-svg PATH`: also write a self-contained SVG document to PATH.
624    export_svg: Option<String>,
625    /// `--panel BOX`: wrap the output in a [`Panel`] with the named box.
626    /// `--panel none` is upstream's default and means *no panel at all*, so it
627    /// parses to `None` rather than to `box.NONE`.
628    panel: Option<BoxSet>,
629    /// `--padding T[,R[,B,L]]`: wrap the output in [`Padding`].
630    padding: Option<(usize, usize, usize, usize)>,
631    /// `--title`: the panel title, and the CSV table's title.
632    title: Option<String>,
633    /// `--caption`: the panel subtitle, and the CSV table's caption.
634    caption: Option<String>,
635    /// `-S/--panel-style`: the panel *border* style.
636    panel_style: Option<Style>,
637    /// `-s/--style`: a style laid under the whole renderable (upstream's
638    /// `Styled`), applied outside the panel.
639    style: Option<Style>,
640    /// `-e/--expand`: make `--panel`/`--padding` fill the available width
641    /// instead of shrinking to their content. Implied by `--width`.
642    expand: bool,
643    /// `--pager`: page the output through the system pager.
644    pager: bool,
645    auto_pager: bool,
646    /// `-y/--hyperlinks`: render a Markdown link as an OSC 8 hyperlink.
647    /// Upstream's flag, and upstream's default of **false** — see
648    /// [`build_markdown`], which is where it is consumed.
649    hyperlinks: bool,
650    /// `--sanitize`: visibly neutralize terminal controls from input and labels.
651    sanitize: bool,
652    /// `--report json`: emit a stable result/error envelope to stderr while
653    /// leaving stdout for rendered content.
654    report_format: ReportFormat,
655    /// Re-render a changing local file or URL while stdout is a terminal.
656    watch: bool,
657    /// Polling interval for `--watch`, in seconds.
658    watch_interval: f64,
659    /// Avoid emitting unchanged URL responses.
660    watch_cache: bool,
661    /// Quiet period, in seconds, that collapses a burst of file events into
662    /// one re-render (`--watch-debounce`).
663    watch_debounce: f64,
664    /// `--watch-poll`: skip file events and poll local files at the interval.
665    watch_poll: bool,
666    /// `--watch-exit-on-error`: end the watch non-zero when a render fails.
667    watch_exit_on_error: bool,
668    batch: bool,
669    progress: bool,
670    continue_on_error: bool,
671    jobs: usize,
672    dry_run: bool,
673    batch_paths: batch_paths::BatchPathOptions,
674    worker_args: Vec<String>,
675    overwrite: bool,
676    collision: CollisionPolicy,
677    data: inspect::DataOptions,
678    tools: tools::ToolOptions,
679    viewers: viewers::ViewerOptions,
680}
681
682/// `--mermaid-backend`.
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684enum MermaidBackend {
685    /// Flowcharts as text; other diagrams as source. The default for fences.
686    Text,
687    /// Mermaid's own CLI first (the `mmdc` feature). The default for
688    /// `rich mermaid` in a build that has it.
689    Mmdc,
690    /// Markdown fences stay code blocks, as upstream renders them.
691    Off,
692}
693
694impl MermaidBackend {
695    fn parse(value: &str) -> Result<Self, String> {
696        match value {
697            "text" => Ok(Self::Text),
698            "off" => Ok(Self::Off),
699            "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
700            "mmdc" => Err(
701                "--mermaid-backend mmdc needs a build with the mmdc feature \
702                 (cargo install rs-rich-cli --features mmdc)"
703                    .into(),
704            ),
705            other => Err(format!(
706                "unknown Mermaid backend {other:?} (text, mmdc, off)"
707            )),
708        }
709    }
710}
711
712/// Mermaid options for a backend.
713#[cfg(feature = "mermaid")]
714fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
715    rich_mermaid::MermaidOptions {
716        backend: match backend {
717            MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
718            MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
719        },
720        ..rich_mermaid::MermaidOptions::default()
721    }
722}
723
724/// The CLI's plugin registry: [`builtin_registry`], then the linked plugins
725/// and the runtime plugins `--plugin` loaded (see [`plugins`]).
726fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
727    plugin_registry_with(mermaid, true)
728}
729
730/// [`plugin_registry`], leaving out the DOT plugin when `dot` is false
731/// (`--dot-backend off`).
732fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
733    let mut registry = builtin_registry(mermaid, dot);
734    plugins::add_to(&mut registry);
735    registry
736}
737
738/// The built-in plugins: rich-ext's, DOT, and Mermaid and lumis where
739/// compiled in.
740fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
741    let mut registry = rich_ext::ExtensionRegistry::with_defaults();
742    if dot {
743        registry
744            .add_plugin(&rich_diagram::plugin::DotPlugin::default())
745            .expect("the DOT plugin registers cleanly");
746    }
747    #[cfg(feature = "mermaid")]
748    if mermaid != MermaidBackend::Off {
749        registry
750            .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
751            .expect("the Mermaid plugin registers cleanly");
752    }
753    #[cfg(not(feature = "mermaid"))]
754    let _ = mermaid;
755    #[cfg(feature = "lumis")]
756    registry
757        .add_plugin(&rich_lumis::LumisPlugin)
758        .expect("the lumis plugin registers cleanly");
759    registry
760}
761
762/// The console-wide code highlighting `--highlighter` and `--code-theme`
763/// choose; `None` when neither is given, which leaves every console on the
764/// default syntect highlighter and its default theme.
765fn code_highlighting(
766    highlighter: Option<&str>,
767    theme: Option<&str>,
768) -> Result<Option<rich::CodeHighlighting>, String> {
769    if highlighter.is_none() && theme.is_none() {
770        return Ok(None);
771    }
772    let mut registry = plugin_registry(MermaidBackend::Off);
773    let name = highlighter.unwrap_or("syntect");
774    if let Err(error) = registry.set_default_code_highlighter(name, theme) {
775        return Err(match error {
776            rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
777                if name == "lumis" && !cfg!(feature = "lumis") =>
778            {
779                format!(
780                    "{error} (lumis needs a build with the lumis feature: cargo install \
781                     rs-rich-cli --features lumis)"
782                )
783            }
784            rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
785                let themes = registry
786                    .code_highlighter(name)
787                    .map(|engine| engine.themes())
788                    .unwrap_or_default();
789                if themes.len() <= 40 {
790                    format!("{error}; its themes: {}", themes.join(", "))
791                } else {
792                    format!(
793                        "{error}; it has {} themes, such as {}; `rich doctor --report json` \
794                         lists them all",
795                        themes.len(),
796                        themes[..12].join(", ")
797                    )
798                }
799            }
800            other => other.to_string(),
801        });
802    }
803    Ok(registry.code_highlighting())
804}
805
806/// Give `console` the code highlighting `cli` chose (parsing checked it).
807fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
808    use rich::ConsoleCodeHighlighting;
809    if let Ok(Some(highlighting)) =
810        code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
811    {
812        console.set_code_highlighting(Some(highlighting));
813    }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817enum CollisionPolicy {
818    Error,
819    Overwrite,
820    Suffix,
821}
822
823/// Build the `-m/--markdown` renderable, honouring `-y/--hyperlinks`.
824///
825/// Upstream is `Markdown(markdown_data, code_theme=theme, hyperlinks=hyperlinks)`
826/// with `@click.option("--hyperlinks", "-y", is_flag=True)`, so the default is
827/// **false**: `rich -m` renders `[text](url)` as `text (url)`, with the URL
828/// visible, and only `-y` turns it into an OSC 8 hyperlink whose target the
829/// reader cannot see.
830///
831/// Without this, `rich -m` emitted the OSC 8 rendering unconditionally and the
832/// destination appeared nowhere in the byte stream — so every URL in a document
833/// was unrecoverable as soon as the output was piped or `NO_COLOR` was set.
834fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
835    Markdown::new(source).hyperlinks(hyperlinks)
836}
837
838/// Run the `rich` command line with `args` (the arguments after the program
839/// name) and return its exit status. The `rich` binary's `main` is only this.
840///
841/// This library target exists for the Python wheel (`crates/rich-py`), which
842/// runs the CLI in-process through [`run_embedded`]; it is not a supported
843/// Rust API, and everything else in it is private. Output goes to the
844/// process's real stdout and stderr and input comes from its stdin; some modes
845/// (`--batch`, `--watch`, the demo) install a process-wide Ctrl-C handler, and
846/// a few error paths end the process.
847///
848/// Arguments that are not valid Unicode are parsed by their escaped
849/// spelling (see [`path_arg`]), and a file named by one is opened by its
850/// original bytes (see [`fs_path`]), as upstream's click does through
851/// `surrogateescape`; text such as `--print`'s shows lossily ([`text_arg`]).
852pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
853    dispatch(remember_raw_args(args))
854}
855
856/// Paths and arguments that were not valid Unicode, by their escaped
857/// spelling ([`escaped_path`]); `None` when two different ones share one
858/// spelling (which then cannot say which it was).
859static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
860    std::sync::Mutex::new(Vec::new());
861
862/// The arguments as `String`s for the parser, keeping the originals of any
863/// that are not valid Unicode for [`fs_path`].
864///
865/// Such an argument is spelled as [`path_arg`] spells a path, after a NUL
866/// that no argument can contain, never by its lossy spelling: `\xff.txt`
867/// and a real `\u{fffd}.txt` share that one, so `rich $'\xff.txt'
868/// --export-html \u{fffd}.txt` resolved the export to the input and
869/// overwrote it.
870fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
871    let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
872    let args = args
873        .into_iter()
874        .map(|arg| match arg.into_string() {
875            Ok(arg) => arg,
876            Err(arg) => {
877                let spelling = escaped_path(&arg);
878                match raw.iter_mut().find(|(known, _)| *known == spelling) {
879                    Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
880                    Some(_) => {}
881                    None => raw.push((spelling.clone(), Some(arg))),
882                }
883                spelling
884            }
885        })
886        .collect();
887    if let Ok(mut slot) = RAW_ARGS.lock() {
888        *slot = raw;
889    }
890    args
891}
892
893/// The file system path an argument names: the original bytes when it was
894/// not valid Unicode (so `rich $'\xff.txt'` opens that file, not
895/// `\u{fffd}.txt`), else the argument itself.
896pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
897    RAW_ARGS
898        .lock()
899        .ok()
900        .and_then(|raw| {
901            raw.iter()
902                .find(|(spelling, _)| spelling == arg)
903                .and_then(|(_, original)| original.clone())
904        })
905        .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
906}
907
908/// An argument used as text rather than a path (`--print`'s text, a
909/// `--title`): an escaped spelling of one that was not valid Unicode shows
910/// lossily, as the text it was meant to be.
911pub(crate) fn text_arg(arg: &str) -> String {
912    if !arg.starts_with('\0') {
913        return arg.to_string();
914    }
915    RAW_ARGS
916        .lock()
917        .ok()
918        .and_then(|raw| {
919            raw.iter()
920                .find(|(spelling, _)| spelling == arg)
921                .and_then(|(_, original)| original.clone())
922        })
923        .map_or_else(
924            || arg.to_string(),
925            |original| original.to_string_lossy().into_owned(),
926        )
927}
928
929/// A path as the `String` the rest of the CLI passes around. A path that is
930/// not valid Unicode (a file found by a batch glob, a planned output named
931/// after one) is spelled with each invalid byte as `\xNN` (and each `\` as
932/// `\x5C`, so no two such paths share a spelling), after a NUL, which no
933/// path or argument can contain, so the spelling never names another file.
934/// It is remembered, so [`fs_path`] gives the original back wherever the
935/// spelling reaches the file system.
936pub(crate) fn path_arg(path: &Path) -> String {
937    if let Some(text) = path.to_str() {
938        return text.to_string();
939    }
940    let spelling = escaped_path(path.as_os_str());
941    if let Ok(mut raw) = RAW_ARGS.lock() {
942        let original = path.as_os_str().to_owned();
943        match raw.iter_mut().find(|(known, _)| *known == spelling) {
944            Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
945            Some(_) => {}
946            None => raw.push((spelling.clone(), Some(original))),
947        }
948    }
949    spelling
950}
951
952/// A NUL, then the Unicode parts of `path` with `\` as `\x5C`, and each
953/// other byte as `\xNN`.
954#[cfg(unix)]
955fn escaped_path(path: &std::ffi::OsStr) -> String {
956    use std::os::unix::ffi::OsStrExt;
957    let mut out = String::from('\0');
958    for chunk in path.as_bytes().utf8_chunks() {
959        out.push_str(&chunk.valid().replace('\\', "\\x5C"));
960        for byte in chunk.invalid() {
961            out.push_str(&format!("\\x{byte:02X}"));
962        }
963    }
964    out
965}
966
967#[cfg(not(unix))]
968fn escaped_path(path: &std::ffi::OsStr) -> String {
969    format!("\0{}", path.to_string_lossy())
970}
971
972/// [`run`] for a host process that is not the `rich` executable: `program` is
973/// the command that starts this CLI again (for the Python wheel,
974/// `[sys.executable, "-m", "rs_rich"]`), used for `--batch` workers and the
975/// demo's child `--watch`, where the binary runs its own executable. An empty
976/// `program` means the current executable, as in [`run`].
977///
978/// A host may call this from several threads at once (the wheel releases the
979/// GIL), but a run's plugins, program and colour settings are process-wide:
980/// runs take turns, each whole, so one never clears another's plugins.
981pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
982    // A run that panicked left nothing half-set that the next run does not
983    // reset first, so a poisoned lock is still usable.
984    let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
985    if let Ok(mut slot) = SELF_PROGRAM.lock() {
986        *slot = program;
987    }
988    run(args)
989}
990
991/// Held for the whole of one [`run_embedded`] call.
992static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
993
994/// The command [`run_embedded`] was given; empty for the binary.
995static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
996
997fn dispatch(args: Vec<String>) -> ExitCode {
998    // An in-process host (`run_embedded`, the Python wheel) runs many
999    // command lines: a run's plugins are only the ones it names.
1000    plugins::install(Vec::new());
1001    if demo::requested(&args) {
1002        return demo::dispatch(&args);
1003    }
1004    if doctor::requested(&args) {
1005        return doctor::dispatch(&args);
1006    }
1007    if plugins::requested(&args) {
1008        return plugins::dispatch(&args);
1009    }
1010    #[cfg(feature = "art")]
1011    if micro::requested(&args) {
1012        return micro::dispatch(&args);
1013    }
1014    #[cfg(feature = "interact")]
1015    if let Some(command) = interactive::requested(&args) {
1016        return interactive::dispatch(command, &args);
1017    }
1018    #[cfg(feature = "record")]
1019    if record::requested(&args) {
1020        return record::dispatch(&args);
1021    }
1022    #[cfg(feature = "serve")]
1023    if serve::requested(&args) {
1024        return serve::dispatch(&args);
1025    }
1026    match parse(&args) {
1027        Ok(None) => ExitCode::SUCCESS, // help/version already printed
1028        Ok(Some(cli)) => {
1029            if let Err(message) = plugins::check_linked() {
1030                return fail(&cli, ExitClass::Usage, message);
1031            }
1032            if !cli.plugins.is_empty() {
1033                match plugins::load(&cli.plugins) {
1034                    Ok(loaded) => plugins::install(loaded),
1035                    Err((class, message)) => return fail(&cli, class, message),
1036                }
1037            }
1038            // `--transform` names are known once plugins are loaded.
1039            if let Some(message) = cli
1040                .data
1041                .unknown_transform(&plugin_registry(MermaidBackend::Text))
1042            {
1043                return fail(&cli, ExitClass::Usage, message);
1044            }
1045            if cli.batch {
1046                if let Err(error) = batch::install_interrupt_handler() {
1047                    return fail(
1048                        &cli,
1049                        ExitClass::Input,
1050                        format!("cannot install batch interruption handler: {error}"),
1051                    );
1052                }
1053            }
1054            run_cli(cli)
1055        }
1056        Err(message) => emit_error(
1057            wants_json_report(&args),
1058            ExitClass::Usage,
1059            &format!("{message} (try --help)"),
1060        ),
1061    }
1062}
1063
1064/// A command that runs this CLI again: a `--batch` worker, or the demo's
1065/// child `--watch`. In the `rich` binary that is the current executable; under
1066/// [`run_embedded`] it is the host's command.
1067fn self_command() -> std::io::Result<std::process::Command> {
1068    let program = SELF_PROGRAM
1069        .lock()
1070        .map(|program| program.clone())
1071        .unwrap_or_default();
1072    Ok(match program.split_first() {
1073        Some((executable, rest)) => {
1074            let mut command = std::process::Command::new(executable);
1075            command.args(rest);
1076            command
1077        }
1078        None => std::process::Command::new(std::env::current_exe()?),
1079    })
1080}
1081
1082/// Options that consume the following argument as their value. Needed to tell a
1083/// positional subcommand from an option's value when scanning raw arguments.
1084const VALUE_OPTIONS: &[&str] = &[
1085    "--batch-input-root",
1086    "--batch-name-template",
1087    "--log-presentation",
1088    "--mermaid-backend",
1089    "--dot-backend",
1090    "--metadata",
1091    "--why",
1092    "--package",
1093    "--timings",
1094    "--audit",
1095    "--depth",
1096    "--kind",
1097    "--x",
1098    "--y",
1099    "--sample",
1100    "--columns",
1101    "--top",
1102    "--highlighter",
1103    "--code-theme",
1104    "--plugin",
1105    "-w",
1106    "--width",
1107    "-o",
1108    "--export-html",
1109    "--export-svg",
1110    "-a",
1111    "--panel",
1112    "-d",
1113    "--padding",
1114    "-h",
1115    "--head",
1116    "-t",
1117    "--tail",
1118    "--lexer",
1119    "-W",
1120    "--max-width",
1121    "--rule-style",
1122    "--rule-char",
1123    "--title",
1124    "--caption",
1125    "-s",
1126    "--style",
1127    "-S",
1128    "--panel-style",
1129    "--report",
1130    "--image-mode",
1131    "--image-fit",
1132    "--image-anchor",
1133    "--image-background",
1134    "--height",
1135    "--theme",
1136    "--theme-style",
1137    "--theme-file",
1138    "--image-color",
1139    "--image-dither",
1140    "--image-color-distance",
1141    "--image-rotate",
1142    "--image-max-width",
1143    "--image-max-height",
1144    "--image-brightness",
1145    "--image-contrast",
1146    "--image-gamma",
1147    "--demo-section",
1148    "--demo-delay",
1149    "--watch-interval",
1150    "--interval",
1151    "--watch-debounce",
1152    "--gif-mode",
1153    "--encoding",
1154    "--threshold",
1155    "--loop",
1156    "--jobs",
1157    "--collision",
1158    "--format",
1159    "--select",
1160    "--filter",
1161    "--highlight",
1162    "--transform",
1163    "--find",
1164    "--max-depth",
1165    "--max-length",
1166    "--compare",
1167    "--context",
1168    "--language",
1169    "--search",
1170    "--offset",
1171    "--length",
1172    "--bytes-per-line",
1173    "--group",
1174    "--limit",
1175    "--cast",
1176    "--redact-pattern",
1177    "--config",
1178    "--profile",
1179];
1180
1181/// Whether the command line already picks a render mode, by flag *or* by the
1182/// positional subcommand form. Config must not inject a `mode` default over
1183/// `rich json file.json`, which selects JSON without ever naming `--json`.
1184fn selects_mode_explicitly(args: &[String]) -> bool {
1185    explicit_mode(args).is_some()
1186}
1187
1188/// The render mode the command line picks by flag or subcommand, if any.
1189fn explicit_mode(args: &[String]) -> Option<Mode> {
1190    let mut iter = args.iter();
1191    let mut seen_positional = false;
1192    while let Some(arg) = iter.next() {
1193        if arg == "--" {
1194            break;
1195        }
1196        if VALUE_OPTIONS.contains(&arg.as_str()) {
1197            iter.next();
1198            continue;
1199        }
1200        if arg.starts_with('-') && arg.len() > 1 {
1201            if let Some(flag) = mode_flag_alias(arg) {
1202                return command_mode(&flag[2..]);
1203            }
1204            continue;
1205        }
1206        if !seen_positional {
1207            seen_positional = true;
1208            if let Some(mode) = command_mode(arg) {
1209                return Some(mode);
1210            }
1211        }
1212    }
1213    None
1214}
1215
1216/// The command word (`hex`, `view`, …) the command line starts with, if its
1217/// first positional is one.
1218fn command_word(args: &[String]) -> Option<&str> {
1219    let mut iter = args.iter();
1220    while let Some(arg) = iter.next() {
1221        if arg == "--" {
1222            return None;
1223        }
1224        if VALUE_OPTIONS.contains(&arg.as_str()) {
1225            iter.next();
1226        } else if !arg.starts_with('-') || arg == "-" {
1227            return command_mode(arg).map(|_| arg.as_str());
1228        }
1229    }
1230    None
1231}
1232
1233/// The first positional word, when it can name a subcommand (`plugins`,
1234/// `doctor`, `record`, `choose`, `bench`, …): never after `--`, and never
1235/// once a render-mode flag came before it, which makes the word a resource
1236/// (`rich -p plugins` prints "plugins"). Every subcommand scanner uses this,
1237/// so they agree with each other and with [`parse`].
1238pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1239    let mut iter = args.iter();
1240    while let Some(arg) = iter.next() {
1241        if arg == "--" || mode_flag_alias(arg).is_some() {
1242            return None;
1243        }
1244        if VALUE_OPTIONS.contains(&arg.as_str()) {
1245            iter.next();
1246        } else if !arg.starts_with('-') || arg == "-" {
1247            return Some(arg);
1248        }
1249    }
1250    None
1251}
1252
1253/// The canonical long flag for a render-mode option, or `None` for anything else.
1254fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1255    Some(match arg {
1256        "--print" | "-p" => "--print",
1257        "--markdown" | "-m" => "--markdown",
1258        "--json" | "-j" | "-J" => "--json",
1259        "--syntax" | "-x" => "--syntax",
1260        "--csv" => "--csv",
1261        "--ipynb" => "--ipynb",
1262        "--rst" => "--rst",
1263        "--jsonl" | "--ndjson" => "--jsonl",
1264        "--log" => "--log",
1265        "--rule" | "-u" => "--rule",
1266        "--image" => "--image",
1267        "--gif" => "--gif",
1268        "--diff" => "--diff",
1269        "--inspect" => "--inspect",
1270        "--ansi-explain" => "--ansi-explain",
1271        _ => return None,
1272    })
1273}
1274
1275fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1276    // Messages quote paths and other input, which may carry terminal controls
1277    // (a file named `x\e]0;title\a.txt`): show them, never execute them.
1278    let message = &controls::shown(message);
1279    if json {
1280        eprintln!(
1281            "{}",
1282            serde_json::json!({
1283                "ok": false,
1284                "code": class.name(),
1285                "exit_code": class.code(),
1286                "message": message,
1287                "error": {
1288                    "message": message,
1289                },
1290            })
1291        );
1292    } else {
1293        eprintln!("rich: {message}");
1294    }
1295    class.exit_code()
1296}
1297
1298fn emit_success_report(format: ReportFormat) {
1299    if format == ReportFormat::Json {
1300        eprintln!(
1301            "{}",
1302            serde_json::json!({
1303                "ok": true,
1304                "code": ExitClass::Success.name(),
1305                "exit_code": ExitClass::Success.code(),
1306                "result": {},
1307            })
1308        );
1309    }
1310}
1311
1312fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1313    let message = controls::shown(message.as_ref());
1314    // A watched resource's live region shows its own error.
1315    if watch::capture_error(&message) {
1316        return class.exit_code();
1317    }
1318    emit_error(cli.report_format == ReportFormat::Json, class, &message)
1319}
1320
1321fn success(cli: &Cli) -> ExitCode {
1322    if !watch::capturing() {
1323        emit_success_report(cli.report_format);
1324    }
1325    ExitClass::Success.exit_code()
1326}
1327
1328/// Match `text` against a simple glob supporting `*` (any run of characters)
1329/// and `?` (exactly one). Both apply within a single path segment.
1330///
1331/// Substring containment is not a glob: `*.md` used that way also matched
1332/// `notes.md.bak`, and a `?` pattern matched nothing at all because the literal
1333/// `?` was still being searched for.
1334#[cfg(test)]
1335fn glob_match(pattern: &str, text: &str) -> bool {
1336    let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1337    let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1338    glob_match_units(&pattern, &text)
1339}
1340
1341/// One unit a glob matches: a character, or a byte of a name that is not
1342/// valid Unicode, which `?` matches alone and nothing else stands for.
1343#[derive(Clone, Copy, PartialEq, Eq)]
1344enum GlobUnit {
1345    Char(char),
1346    Byte(u8),
1347}
1348
1349/// A file name's units, from its bytes where it is not valid Unicode:
1350/// matching its lossy spelling let `a\xff*` match `a\xfe.txt`.
1351fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1352    #[cfg(unix)]
1353    {
1354        use std::os::unix::ffi::OsStrExt;
1355        let mut units = Vec::new();
1356        for chunk in name.as_bytes().utf8_chunks() {
1357            units.extend(chunk.valid().chars().map(GlobUnit::Char));
1358            units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1359        }
1360        units
1361    }
1362    #[cfg(not(unix))]
1363    {
1364        name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1365    }
1366}
1367
1368fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1369    const ANY: GlobUnit = GlobUnit::Char('?');
1370    const STAR: GlobUnit = GlobUnit::Char('*');
1371    let (mut p, mut t) = (0usize, 0usize);
1372    let (mut star, mut retry) = (None, 0usize);
1373    while t < text.len() {
1374        if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1375            p += 1;
1376            t += 1;
1377        } else if p < pattern.len() && pattern[p] == STAR {
1378            star = Some(p);
1379            retry = t;
1380            p += 1;
1381        } else if let Some(open) = star {
1382            p = open + 1;
1383            retry += 1;
1384            t = retry;
1385        } else {
1386            return false;
1387        }
1388    }
1389    while p < pattern.len() && pattern[p] == STAR {
1390        p += 1;
1391    }
1392    p == pattern.len()
1393}
1394
1395fn has_glob_meta(value: &str) -> bool {
1396    value.contains('*') || value.contains('?')
1397}
1398
1399/// Split `dir/sub/*.md` into its literal directory and its name pattern.
1400fn split_glob(resource: &str) -> (PathBuf, String) {
1401    let meta = resource.find(['*', '?']).unwrap_or(0);
1402    match resource[..meta].rfind(['/', '\\']) {
1403        Some(cut) => (
1404            PathBuf::from(&resource[..cut]),
1405            resource[cut + 1..].to_string(),
1406        ),
1407        None => (PathBuf::from("."), resource.to_string()),
1408    }
1409}
1410
1411/// Collect every file under `dir`, skipping symlinks.
1412///
1413/// `Path::is_dir` follows links, so a directory containing a link to itself or
1414/// to an ancestor pushed that directory back onto the stack forever, building
1415/// ever longer paths until the process ran out of memory. Not following links
1416/// at all keeps the walk finite without having to track device/inode identity,
1417/// which is not portable.
1418fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1419    let mut stack = vec![dir.to_path_buf()];
1420    while let Some(current) = stack.pop() {
1421        batch::check_interrupted()?;
1422        let entries = std::fs::read_dir(&current)
1423            .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1424        for entry in entries {
1425            batch::check_interrupted()?;
1426            let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1427            let path = entry.path();
1428            let kind = std::fs::symlink_metadata(&path)
1429                .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1430                .file_type();
1431            if kind.is_symlink() {
1432                continue;
1433            }
1434            if kind.is_dir() {
1435                stack.push(path);
1436            } else if kind.is_file() {
1437                out.push(path_arg(&path));
1438            }
1439        }
1440    }
1441    Ok(())
1442}
1443
1444fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1445    let mut out = Vec::new();
1446    for resource in resources {
1447        batch::check_interrupted()?;
1448        if resource == "-" || is_url(resource) {
1449            return Err("batch resources must be local files or directories".into());
1450        }
1451        if has_glob_meta(resource) {
1452            // A glob that was not valid Unicode is split and matched on its
1453            // own bytes: only the whole argument's are remembered, and its
1454            // escaped spelling spells each such byte with a `\`.
1455            let raw = fs_path(resource);
1456            let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1457            let (dir, pattern) = if escaped {
1458                let parent = raw.parent().unwrap_or(Path::new(""));
1459                if has_glob_meta(&parent.to_string_lossy()) {
1460                    return Err(format!(
1461                        "batch glob {} may only use * and ? in the final path segment",
1462                        controls::shown(resource)
1463                    ));
1464                }
1465                let dir = if parent.as_os_str().is_empty() {
1466                    PathBuf::from(".")
1467                } else {
1468                    parent.to_path_buf()
1469                };
1470                (dir, glob_units(raw.file_name().unwrap_or_default()))
1471            } else {
1472                let (dir, pattern) = split_glob(resource);
1473                if pattern.contains('/') || pattern.contains('\\') {
1474                    return Err(format!(
1475                        "batch glob {resource:?} may only use * and ? in the final path segment"
1476                    ));
1477                }
1478                (dir, pattern.chars().map(GlobUnit::Char).collect())
1479            };
1480            let entries = std::fs::read_dir(&dir)
1481                .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1482            for entry in entries {
1483                batch::check_interrupted()?;
1484                let entry =
1485                    entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1486                let path = entry.path();
1487                if !path.is_file() {
1488                    continue;
1489                }
1490                if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1491                    out.push(path_arg(&path));
1492                }
1493            }
1494            continue;
1495        }
1496        let path = fs_path(resource);
1497        if path.is_file() {
1498            out.push(resource.clone());
1499        } else if path.is_dir() {
1500            collect_files(&path, &mut out)?;
1501        } else {
1502            return Err(format!("batch resource does not exist: {resource}"));
1503        }
1504    }
1505    batch::check_interrupted()?;
1506    out.sort();
1507    out.dedup();
1508    batch::check_interrupted()?;
1509    if out.is_empty() {
1510        return Err("batch plan contains no files".into());
1511    }
1512    Ok(out)
1513}
1514
1515/// Where one batch item's exports are written.
1516///
1517/// The kind of each export is carried explicitly. Recovering it by sniffing the
1518/// path extension silently dropped exports whose name did not end in `.html` or
1519/// `.svg`, so `--export-html result.out` planned a path and then wrote nothing.
1520#[derive(Default, Clone)]
1521struct BatchOutputs {
1522    html: Option<String>,
1523    svg: Option<String>,
1524}
1525
1526fn batch_export_path(
1527    path: Option<&str>,
1528    input: &str,
1529    index: usize,
1530    total: usize,
1531) -> Option<String> {
1532    let path = path?;
1533    if total == 1 {
1534        return Some(path.into());
1535    }
1536    let p = fs_path(path);
1537    // The input's stem as its bytes: a non-Unicode name keeps them.
1538    let input = fs_path(input);
1539    let stem = input
1540        .file_stem()
1541        .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1542    let mut name = stem.to_owned();
1543    match p.extension() {
1544        Some(ext) if !ext.is_empty() => {
1545            name.push(".");
1546            name.push(ext);
1547        }
1548        _ => name.push(format!("-{index}")),
1549    }
1550    Some(path_arg(&if p.is_dir() {
1551        p.join(name)
1552    } else {
1553        p.with_file_name(name)
1554    }))
1555}
1556
1557/// `out.html` -> `out-2.html`, keeping an extension-less name intact.
1558fn suffixed_path(path: &Path, suffix: usize) -> String {
1559    let mut name = path
1560        .file_stem()
1561        .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1562        .to_owned();
1563    name.push(format!("-{suffix}"));
1564    if let Some(ext) = path.extension() {
1565        name.push(".");
1566        name.push(ext);
1567    }
1568    path_arg(&path.with_file_name(name))
1569}
1570
1571/// Settle one planned output against the rest of the plan and against the disk.
1572///
1573/// `suffix` has to consider existing files too, not just in-plan duplicates:
1574/// a policy whose whole purpose is to avoid clobbering must not quietly
1575/// overwrite a file that was already there.
1576fn resolve_destination(
1577    cli: &Cli,
1578    candidate: String,
1579    taken: &mut std::collections::BTreeSet<String>,
1580) -> Result<String, (ExitClass, String)> {
1581    let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1582        taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1583    };
1584    match cli.collision {
1585        CollisionPolicy::Overwrite => {
1586            taken.insert(candidate.clone());
1587            Ok(candidate)
1588        }
1589        CollisionPolicy::Suffix => {
1590            let mut chosen = candidate.clone();
1591            let mut suffix = 2;
1592            while occupied(&chosen, taken) {
1593                batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1594                chosen = suffixed_path(&fs_path(&candidate), suffix);
1595                suffix += 1;
1596            }
1597            taken.insert(chosen.clone());
1598            Ok(chosen)
1599        }
1600        CollisionPolicy::Error => {
1601            if taken.contains(&candidate) {
1602                return Err((
1603                    ExitClass::Usage,
1604                    format!("batch output collision: {candidate}"),
1605                ));
1606            }
1607            if !cli.overwrite && fs_path(&candidate).exists() {
1608                return Err((
1609                    ExitClass::Input,
1610                    format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1611                ));
1612            }
1613            taken.insert(candidate.clone());
1614            Ok(candidate)
1615        }
1616    }
1617}
1618
1619/// Run one batch item with its diagnostics captured, yielding its failure class.
1620/// Reuse resolved rendering flags without re-reading config in batch workers.
1621fn worker_args(args: &[String]) -> Vec<String> {
1622    let mut result = vec!["--no-config".into()];
1623    let mut iter = args.iter();
1624    let mut seen_resource = false;
1625    let mut selected_mode = false;
1626    while let Some(arg) = iter.next() {
1627        if arg == "--" {
1628            break;
1629        }
1630        if VALUE_OPTIONS.contains(&arg.as_str()) {
1631            let value = iter.next();
1632            if !matches!(
1633                arg.as_str(),
1634                "--jobs"
1635                    | "--batch-input-root"
1636                    | "--batch-name-template"
1637                    | "--collision"
1638                    | "--report"
1639                    | "-o"
1640                    | "--export-html"
1641                    | "--export-svg"
1642                    | "--config"
1643                    | "--profile"
1644            ) {
1645                result.push(arg.clone());
1646                if let Some(value) = value {
1647                    result.push(value.clone());
1648                }
1649            }
1650        } else if arg.starts_with('-') && arg != "-" {
1651            selected_mode |= mode_flag_alias(arg).is_some();
1652            if !matches!(
1653                arg.as_str(),
1654                "--batch"
1655                    | "--batch-preserve-dirs"
1656                    | "--no-batch-preserve-dirs"
1657                    | "--no-batch"
1658                    | "--dry-run"
1659                    | "--progress"
1660                    | "--no-progress"
1661                    | "--continue-on-error"
1662                    | "--no-continue-on-error"
1663                    | "--overwrite"
1664                    | "--no-overwrite"
1665                    | "--machine-json"
1666            ) {
1667                result.push(arg.clone());
1668            }
1669        } else if !seen_resource {
1670            if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1671                selected_mode = true;
1672                result.push(format!("--{}", mode_name(mode)));
1673            } else {
1674                seen_resource = true;
1675            }
1676        }
1677    }
1678    result
1679}
1680
1681fn wants_json_report(args: &[String]) -> bool {
1682    let mut format = ReportFormat::Human;
1683    let mut iter = args.iter();
1684    while let Some(arg) = iter.next() {
1685        match arg.as_str() {
1686            "--report" => {
1687                if let Some(value) = iter.next() {
1688                    let _ = format.apply_option("--report", Some(value));
1689                }
1690            }
1691            "--machine-json" => {
1692                let _ = format.apply_option("--machine-json", None);
1693            }
1694            "--" => break,
1695            _ => {}
1696        }
1697    }
1698    format == ReportFormat::Json
1699}
1700
1701/// Map a `--panel` box name to a box set (port of rich-cli's `BOXES` +
1702/// `getattr(box, name.upper())`).
1703///
1704/// `none` is upstream's *default* for `--panel`, and upstream guards the whole
1705/// panel step with `if panel != "none"` — so it means "no panel", not "a panel
1706/// drawn with `box.NONE`". Drawing box.NONE put an invisible one-cell frame and
1707/// a blank line around the output, which reads as the tool having mangled the
1708/// file for no reason.
1709///
1710/// rich-cli offers six names; this binary takes every box in rich's `box`
1711/// module (the names `rich asset --kind box` offers), the six first.
1712fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1713    let name = name.to_ascii_lowercase();
1714    if name == "none" {
1715        return Ok(None);
1716    }
1717    PANEL_BOXES
1718        .iter()
1719        .find(|(known, _)| *known == name)
1720        .map(|(_, set)| Some(*set))
1721        .ok_or_else(|| {
1722            let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1723            format!(
1724                "unknown panel box {name:?} (use none or {})",
1725                names.join(", ")
1726            )
1727        })
1728}
1729
1730/// `--panel` names: rich-cli's six, then the rest of rich's `box` module.
1731pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1732    use rich::r#box as b;
1733    &[
1734        ("ascii", b::ASCII),
1735        ("ascii2", b::ASCII2),
1736        ("square", b::SQUARE),
1737        ("rounded", b::ROUNDED),
1738        ("heavy", b::HEAVY),
1739        ("double", b::DOUBLE),
1740        ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1741        ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1742        ("minimal", b::MINIMAL),
1743        ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1744        ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1745        ("simple", b::SIMPLE),
1746        ("simple_head", b::SIMPLE_HEAD),
1747        ("simple_heavy", b::SIMPLE_HEAVY),
1748        ("horizontals", b::HORIZONTALS),
1749        ("heavy_edge", b::HEAVY_EDGE),
1750        ("heavy_head", b::HEAVY_HEAD),
1751        ("double_edge", b::DOUBLE_EDGE),
1752        ("markdown", b::MARKDOWN),
1753    ]
1754};
1755
1756/// Parse a `--padding` value: 1, 2, or 4 comma-separated integers, unpacked into
1757/// `(top, right, bottom, left)`. Port of rich-cli's padding parsing + upstream's
1758/// `Padding.unpack`.
1759fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1760    let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1761    let parts: Result<Vec<usize>, _> = value
1762        .split(',')
1763        .map(|p| p.trim().parse::<usize>())
1764        .collect();
1765    let parts = parts.map_err(|_| error())?;
1766    match parts.as_slice() {
1767        [p] => Ok((*p, *p, *p, *p)),
1768        [v, h] => Ok((*v, *h, *v, *h)),
1769        [t, r, b, l] => Ok((*t, *r, *b, *l)),
1770        _ => Err(error()),
1771    }
1772}
1773
1774/// Set the render mode, rejecting a second, conflicting mode flag.
1775fn mode_name(mode: Mode) -> &'static str {
1776    MODE_SPECS
1777        .iter()
1778        .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1779        .unwrap_or("unknown")
1780}
1781
1782fn command_mode(command: &str) -> Option<Mode> {
1783    MODE_SPECS
1784        .iter()
1785        .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1786}
1787
1788/// `--head`/`--tail`'s LINES: upstream's `click.IntRange(min=1)`.
1789fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1790    let flag = match flag {
1791        "-h" | "--head" => "--head",
1792        _ => "--tail",
1793    };
1794    let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1795    match value.parse::<i64>() {
1796        Ok(lines) if lines >= 1 => Ok(lines as usize),
1797        Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1798        Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1799    }
1800}
1801
1802fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1803    if *current != Mode::Auto && *current != mode {
1804        return Err(format!(
1805            "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1806        ));
1807    }
1808    *current = mode;
1809    Ok(())
1810}
1811
1812/// Parse args into a [`Cli`], or `Ok(None)` when `--help`/`--version` handled it.
1813fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1814    let roots = ConfigRoots::default();
1815    if authoring::dispatch(args)? {
1816        return Ok(None);
1817    }
1818    if tools::bench_dispatch(args)? {
1819        return Ok(None);
1820    }
1821    if let Some(output) = config::inspect(args, &roots)? {
1822        authoring::out(&format!("{output}\n"));
1823        return Ok(None);
1824    }
1825    let merged = config_args(args, &roots)?;
1826    parse_inner(&merged)
1827}
1828
1829fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1830    let mut mode = Mode::Auto;
1831    let mut resources: Vec<String> = Vec::new();
1832    let mut loops = None;
1833    let mut diff_threshold = None;
1834    let mut image_mode = ImageMode::Auto;
1835    let mut image_fit = None;
1836    let mut image_anchor = None;
1837    let mut image_background = None;
1838    let mut image_color = None;
1839    let mut image_dither = None;
1840    let mut image_color_distance = None;
1841    let mut image_rotate = None;
1842    let mut image_flip_horizontal = false;
1843    let mut image_flip_vertical = false;
1844    let mut image_grayscale = false;
1845    let mut image_max_width = None;
1846    let mut image_max_height = None;
1847    let mut image_brightness = None;
1848    let mut image_contrast = None;
1849    let mut image_gamma = None;
1850    let mut log_presentation = String::from("plain");
1851    let mut mermaid_backend = None;
1852    let mut dot_backend = None;
1853    let mut graph_sources = sources::GraphSourceOptions::default();
1854    let mut chart = chart::ChartOptions::default();
1855    let mut schema_options = sources::SchemaOptions::default();
1856    let mut profile_options = profile::ProfileOptions::default();
1857    let mut highlighter: Option<String> = None;
1858    let mut plugins: Vec<String> = Vec::new();
1859    let mut code_theme: Option<String> = None;
1860    let mut theme_styles = std::collections::BTreeMap::new();
1861    let mut theme_file_styles = std::collections::BTreeMap::new();
1862    let mut height = None;
1863    let mut extensions = CliExtensions::default();
1864    let mut data = inspect::DataOptions::default();
1865    let mut tool_options = tools::ToolOptions::default();
1866    let mut viewer_options = viewers::ViewerOptions::default();
1867    // Whether any paging flag was given: `view` pages automatically otherwise.
1868    let mut paging_explicit = false;
1869    // `rich ansi explain FILE`: the second word belongs to the command.
1870    let mut ansi_word = false;
1871    let mut width = None;
1872    let mut justify = None;
1873    let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1874    let mut export_html: Option<String> = None;
1875    let mut export_svg: Option<String> = None;
1876    let mut panel = None;
1877    let mut padding = None;
1878    let mut title = None;
1879    let mut caption = None;
1880    let mut panel_style = None;
1881    let mut style = None;
1882    let mut expand = false;
1883    let mut pager = false;
1884    let mut auto_pager = false;
1885    let mut hyperlinks = false;
1886    let mut source = SourceOptions::default();
1887    // Upstream's `if text_left … elif text_right … elif text_center … elif
1888    // text_full`: the first of these in that order wins, wherever it was given.
1889    let mut text_flags = [false; 4];
1890    let mut emoji = false;
1891    let mut soft = false;
1892    let mut max_width = None;
1893    let mut rule_style = None;
1894    let mut rule_char = None;
1895    let mut force_terminal = false;
1896    // Unset means each command's default: off for the upstream modes, on
1897    // for `view` and text `diff` (see `run_once_with_fetch`).
1898    let mut sanitize = None;
1899    let mut report_format = ReportFormat::Human;
1900    let mut watch = false;
1901    let mut watch_interval = 1.0;
1902    let mut watch_cache = false;
1903    let mut watch_debounce: Option<f64> = None;
1904    let mut watch_poll = false;
1905    let mut watch_exit_on_error = false;
1906    let mut batch = false;
1907    let mut progress = true;
1908    let mut micro_project = false;
1909    let mut continue_on_error = false;
1910    let mut jobs = 1usize;
1911    let mut dry_run = false;
1912    let mut batch_paths = batch_paths::BatchPathOptions::default();
1913    let mut overwrite = false;
1914    let mut collision = CollisionPolicy::Error;
1915    // Set by `--`: everything after it is a positional argument, however much it
1916    // looks like a flag. Without this nothing beginning with `-` could be
1917    // printed or opened at all — `rich -p -- "-5 degrees"` and `rich -- -weird.md`
1918    // both died with "unknown option".
1919    let mut end_of_options = false;
1920
1921    let mut iter = args.iter();
1922    while let Some(arg) = iter.next() {
1923        if end_of_options {
1924            resources.push(arg.to_string());
1925            continue;
1926        }
1927        if extensions.parse_option(arg, &mut iter)? {
1928            continue;
1929        }
1930        if data.parse_option(arg, &mut iter)? {
1931            continue;
1932        }
1933        if tool_options.parse_option(arg, &mut iter)? {
1934            continue;
1935        }
1936        if viewer_options.parse_option(arg, &mut iter)? {
1937            continue;
1938        }
1939        if graph_sources.parse_option(arg, &mut iter)? {
1940            continue;
1941        }
1942        if chart.parse_option(arg, &mut iter)? {
1943            continue;
1944        }
1945        if schema_options.parse_option(arg) {
1946            continue;
1947        }
1948        if profile_options.parse_option(arg, &mut iter)? {
1949            continue;
1950        }
1951        if matches!(
1952            arg.as_str(),
1953            "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
1954        ) {
1955            paging_explicit = true;
1956        }
1957        match arg.as_str() {
1958            "--" => end_of_options = true,
1959            // `-h` is upstream's `--head`: help is `--help` only.
1960            "--help" => {
1961                let no_color = no_color || cli_spec::no_color_requested(args);
1962                // `rich hex --help` shows the command, not the whole CLI.
1963                let help = command_word(args)
1964                    .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
1965                match help {
1966                    Some(help) => authoring::out(&format!("{help}\n")),
1967                    None => print_help(no_color),
1968                }
1969                return Ok(None);
1970            }
1971            "-V" | "-v" | "--version" => {
1972                println!("rich (rs-rich-cli) {VERSION}");
1973                return Ok(None);
1974            }
1975            "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
1976            "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
1977            // Upstream's JSON is `-J`; this port's `-j` (upstream's `--emoji`)
1978            // predates the rest of the short aliases. See DIVERGENCES.
1979            "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
1980            "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
1981            "--csv" => set_mode(&mut mode, Mode::Csv)?,
1982            "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
1983            "--rst" => set_mode(&mut mode, Mode::Rst)?,
1984            "--gif" => set_mode(&mut mode, Mode::Gif)?,
1985            "--diff" => set_mode(&mut mode, Mode::Diff)?,
1986            "--image" => set_mode(&mut mode, Mode::Image)?,
1987            "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
1988            "--log" => set_mode(&mut mode, Mode::Log)?,
1989            "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
1990            "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
1991            "--highlighter" => {
1992                let value = iter
1993                    .next()
1994                    .ok_or("--highlighter requires a code highlighter's name")?;
1995                highlighter = Some(value.clone());
1996            }
1997            "--code-theme" => {
1998                let value = iter.next().ok_or("--code-theme requires a theme name")?;
1999                code_theme = Some(value.clone());
2000            }
2001            "--plugin" => {
2002                let path = iter.next().ok_or("--plugin requires PATH")?;
2003                plugins.push(path.clone());
2004            }
2005            "--mermaid-backend" => {
2006                let value = iter
2007                    .next()
2008                    .ok_or("--mermaid-backend requires text, mmdc or off")?;
2009                mermaid_backend = Some(MermaidBackend::parse(value)?);
2010            }
2011            "--dot-backend" => {
2012                let value = iter
2013                    .next()
2014                    .ok_or("--dot-backend requires text, graphviz or off")?;
2015                dot_backend = Some(sources::DotBackend::parse(value)?);
2016            }
2017            "--log-presentation" => {
2018                let value = iter
2019                    .next()
2020                    .ok_or("--log-presentation requires plain or rich")?;
2021                if !matches!(value.as_str(), "plain" | "rich") {
2022                    return Err("--log-presentation requires plain or rich".into());
2023                }
2024                log_presentation = value.clone();
2025            }
2026            "--report" => {
2027                let value = iter.next().map(String::as_str);
2028                report_format.apply_option("--report", value)?;
2029            }
2030            "--machine-json" => {
2031                report_format.apply_option("--machine-json", None)?;
2032            }
2033            "--progress" => progress = true,
2034            "--no-progress" => progress = false,
2035            "--micro-project" => micro_project = true,
2036            "--no-micro-project" => micro_project = false,
2037            "--theme-style" => {
2038                let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2039                let (name, value) = binding
2040                    .split_once('=')
2041                    .ok_or("--theme-style requires NAME=STYLE")?;
2042                config::validate_theme_binding(name, value)?;
2043                let style = Style::parse(value)
2044                    .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2045                theme_styles.insert(name.to_owned(), style);
2046            }
2047            "--theme-file" => {
2048                let path = iter.next().ok_or("--theme-file requires PATH")?;
2049                theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2050            }
2051            "--image-color" => {
2052                const USAGE: &str =
2053                    "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2054                let value = iter.next().ok_or(USAGE)?;
2055                if !matches!(
2056                    value.as_str(),
2057                    "truecolor" | "ansi256" | "ansi16" | "grayscale"
2058                ) {
2059                    return Err(USAGE.into());
2060                }
2061                image_color = Some(value.clone());
2062            }
2063            flag @ ("--image-max-width" | "--image-max-height") => {
2064                let usage = format!("{flag} requires a positive integer");
2065                let value = iter
2066                    .next()
2067                    .and_then(|v| v.parse::<usize>().ok())
2068                    .filter(|v| *v > 0)
2069                    .ok_or(usage)?;
2070                if flag == "--image-max-width" {
2071                    image_max_width = Some(value);
2072                } else {
2073                    image_max_height = Some(value);
2074                }
2075            }
2076            flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2077                let gamma = flag == "--image-gamma";
2078                let usage = if gamma {
2079                    format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2080                } else {
2081                    format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2082                };
2083                let value = iter
2084                    .next()
2085                    .and_then(|v| v.parse::<f32>().ok())
2086                    .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2087                    .ok_or(usage)?;
2088                match flag {
2089                    "--image-brightness" => image_brightness = Some(value),
2090                    "--image-contrast" => image_contrast = Some(value),
2091                    _ => image_gamma = Some(value),
2092                }
2093            }
2094            "--image-flip-horizontal" => image_flip_horizontal = true,
2095            "--no-image-flip-horizontal" => image_flip_horizontal = false,
2096            "--image-flip-vertical" => image_flip_vertical = true,
2097            "--no-image-flip-vertical" => image_flip_vertical = false,
2098            "--image-grayscale" => image_grayscale = true,
2099            "--no-image-grayscale" => image_grayscale = false,
2100            "--image-rotate" => {
2101                let value = iter
2102                    .next()
2103                    .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2104                image_rotate = Some(match value.as_str() {
2105                    "0" => 0,
2106                    "90" => 90,
2107                    "180" => 180,
2108                    "270" => 270,
2109                    _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2110                });
2111            }
2112            "--image-dither" => {
2113                const USAGE: &str =
2114                    "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2115                let value = iter.next().ok_or(USAGE)?;
2116                if !matches!(
2117                    value.as_str(),
2118                    "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2119                ) {
2120                    return Err(USAGE.into());
2121                }
2122                image_dither = Some(value.clone());
2123            }
2124            "--image-color-distance" => {
2125                const USAGE: &str = "--image-color-distance requires rgb or oklab";
2126                let value = iter.next().ok_or(USAGE)?;
2127                if !matches!(value.as_str(), "rgb" | "oklab") {
2128                    return Err(USAGE.into());
2129                }
2130                image_color_distance = Some(value.clone());
2131            }
2132            "--image-mode" => {
2133                let value = iter.next().ok_or(
2134                    "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2135                )?;
2136                image_mode = value.parse()?;
2137            }
2138            "--image-fit" => {
2139                let value = iter
2140                    .next()
2141                    .ok_or("--image-fit requires contain, cover, stretch or native")?;
2142                if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2143                    return Err("--image-fit requires contain, cover, stretch or native".into());
2144                }
2145                image_fit = Some(value.clone());
2146            }
2147            "--image-anchor" => {
2148                let value = iter.next().ok_or("--image-anchor requires a position")?;
2149                if !matches!(
2150                    value.as_str(),
2151                    "center"
2152                        | "top"
2153                        | "bottom"
2154                        | "left"
2155                        | "right"
2156                        | "top-left"
2157                        | "top-right"
2158                        | "bottom-left"
2159                        | "bottom-right"
2160                ) {
2161                    return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2162                }
2163                image_anchor = Some(value.clone());
2164            }
2165            "--image-background" => {
2166                const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2167                let value = iter.next().ok_or(USAGE)?;
2168                image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2169            }
2170            "--height" => {
2171                let value = iter.next().ok_or("--height requires a number")?;
2172                let rows: usize = value
2173                    .parse()
2174                    .map_err(|_| format!("invalid height '{value}'"))?;
2175                if rows == 0 {
2176                    return Err("--height must be at least 1".into());
2177                }
2178                height = Some(rows);
2179            }
2180            "--threshold" => {
2181                let value = iter
2182                    .next()
2183                    .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2184                let percent: f32 = value
2185                    .parse()
2186                    .map_err(|_| format!("invalid threshold '{value}'"))?;
2187                // `"NaN"` and `"inf"` both parse as f32, and every comparison
2188                // against NaN is false — so an empty template variable or a bad
2189                // substitution would turn the gate off and report a pass. A gate
2190                // that can be silently disabled is worse than no gate.
2191                if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2192                    return Err(format!(
2193                        "threshold must be a percentage between 0 and 100, got '{value}'"
2194                    ));
2195                }
2196                diff_threshold = Some(percent);
2197            }
2198            "--loop" => {
2199                let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2200                loops = Some(
2201                    value
2202                        .parse()
2203                        .map_err(|_| format!("invalid loop count {value:?}"))?,
2204                );
2205            }
2206            "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2207            "-l" | "--left" => justify = Some(Justify::Left),
2208            "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2209            "-r" | "--right" => {}
2210            "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2211            "-c" | "--center" => {}
2212            "-L" | "--text-left" => text_flags[0] = true,
2213            "-R" | "--text-right" => text_flags[1] = true,
2214            "-C" | "--text-center" => text_flags[2] = true,
2215            "-F" | "--text-full" => text_flags[3] = true,
2216            "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2217            "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2218            "-n" | "--line-numbers" => source.line_numbers = true,
2219            "-g" | "--guides" => source.guides = true,
2220            "--lexer" => {
2221                source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2222            }
2223            "--no-wrap" => source.no_wrap = true,
2224            "--infer" => source.infer = true,
2225            "--emoji" => emoji = true,
2226            "--soft" => soft = true,
2227            "-W" | "--max-width" => {
2228                let value = iter.next().ok_or("--max-width requires a SIZE")?;
2229                // Upstream's default is -1, and a size of 0 or less means none.
2230                let size: i64 = value
2231                    .parse()
2232                    .map_err(|_| format!("invalid --max-width '{value}'"))?;
2233                max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2234            }
2235            "--rule-style" => {
2236                let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2237                rule_style = Some(
2238                    Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2239                );
2240            }
2241            "--rule-char" => {
2242                rule_char = Some(
2243                    iter.next()
2244                        .ok_or("--rule-char requires a CHARACTER")?
2245                        .clone(),
2246                );
2247            }
2248            "--force-terminal" => force_terminal = true,
2249            "--no-color" => no_color = true,
2250            "--pager" => {
2251                pager = true;
2252                auto_pager = false;
2253            }
2254            "--auto-pager" => {
2255                auto_pager = true;
2256                pager = false;
2257            }
2258            "--no-pager" => {
2259                pager = false;
2260                auto_pager = false;
2261            }
2262            "--no-auto-pager" => auto_pager = false,
2263            "--color" => no_color = false,
2264            "--no-watch" => watch = false,
2265            "--no-watch-cache" => watch_cache = false,
2266            "--no-watch-poll" => watch_poll = false,
2267            "--no-watch-exit-on-error" => watch_exit_on_error = false,
2268            "--no-batch" => batch = false,
2269            "--no-continue-on-error" => continue_on_error = false,
2270            "--no-overwrite" => {
2271                overwrite = false;
2272                collision = CollisionPolicy::Error;
2273            }
2274            "--no-sanitize" => sanitize = Some(false),
2275            "--dry-run" => dry_run = true,
2276            "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2277            "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2278            "--batch-input-root" => {
2279                batch_paths.input_root = Some(fs_path(
2280                    iter.next().ok_or("--batch-input-root requires a path")?,
2281                ))
2282            }
2283            "--batch-name-template" => {
2284                batch_paths.template = Some(
2285                    batch_paths::FilenameTemplate::parse(
2286                        iter.next()
2287                            .ok_or("--batch-name-template requires a template")?,
2288                    )
2289                    .map_err(|e| e.to_string())?,
2290                )
2291            }
2292            "--watch" => watch = true,
2293            "--watch-cache" => watch_cache = true,
2294            "--watch-poll" => watch_poll = true,
2295            "--watch-exit-on-error" => watch_exit_on_error = true,
2296            "--watch-debounce" => {
2297                let value = iter
2298                    .next()
2299                    .ok_or("--watch-debounce requires seconds (0 or more)")?;
2300                let seconds = value
2301                    .parse::<f64>()
2302                    .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2303                if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2304                    return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2305                }
2306                watch_debounce = Some(seconds);
2307            }
2308            "--watch-interval" | "--interval" => {
2309                let value = iter
2310                    .next()
2311                    .ok_or("--watch-interval requires seconds greater than zero")?;
2312                watch_interval = value
2313                    .parse::<f64>()
2314                    .map_err(|_| format!("invalid watch interval {value:?}"))?;
2315                if !watch_interval.is_finite() || watch_interval <= 0.0 {
2316                    return Err("--watch-interval must be greater than zero".into());
2317                }
2318            }
2319            "--batch" => batch = true,
2320            "--continue-on-error" => continue_on_error = true,
2321            "--overwrite" => {
2322                overwrite = true;
2323                collision = CollisionPolicy::Overwrite;
2324            }
2325            "--collision" => {
2326                collision = match iter
2327                    .next()
2328                    .ok_or("--collision requires error, overwrite, or suffix")?
2329                    .as_str()
2330                {
2331                    "error" => CollisionPolicy::Error,
2332                    "overwrite" => CollisionPolicy::Overwrite,
2333                    "suffix" => CollisionPolicy::Suffix,
2334                    other => return Err(format!("unknown collision policy {other:?}")),
2335                };
2336                overwrite = collision == CollisionPolicy::Overwrite;
2337            }
2338            "--jobs" => {
2339                jobs = iter
2340                    .next()
2341                    .ok_or("--jobs requires a positive number")?
2342                    .parse()
2343                    .map_err(|_| "--jobs requires a positive number".to_string())?;
2344                if jobs == 0 {
2345                    return Err("--jobs must be at least 1".into());
2346                }
2347            }
2348            "--sanitize" => sanitize = Some(true),
2349            // Upstream's `@click.option("--hyperlinks", "-y", is_flag=True,
2350            // help="Render hyperlinks in markdown.")`. Accepted in every mode,
2351            // as click accepts it — it is read only where markdown is rendered.
2352            "-y" | "--hyperlinks" => hyperlinks = true,
2353            // Both take a PATH and both may be given at once, matching
2354            // upstream: the resource still renders to the terminal and the
2355            // files are written alongside it.
2356            "--export-html" | "-o" => {
2357                export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2358            }
2359            "--export-svg" => {
2360                export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2361            }
2362            "-a" | "--panel" => {
2363                let value = iter.next().ok_or("--panel requires a box name")?;
2364                panel = parse_box(value)?;
2365            }
2366            "-d" | "--padding" => {
2367                let value = iter.next().ok_or("--padding requires a value")?;
2368                padding = Some(parse_padding(value)?);
2369            }
2370            "-e" | "--expand" => expand = true,
2371            "--title" => {
2372                title = Some(iter.next().ok_or("--title requires a value")?.clone());
2373            }
2374            "--caption" => {
2375                caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2376            }
2377            // `-s/--style` is the style of the *renderable* (upstream wraps it in
2378            // `Styled`); `-S/--panel-style` is the border. They were fused into
2379            // one flag that set the border, so there was no way to style the
2380            // content at all and `--style` aborted without `--panel`.
2381            "-s" | "--style" => {
2382                let value = iter.next().ok_or("--style requires a value")?;
2383                style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2384            }
2385            "-S" | "--panel-style" => {
2386                let value = iter.next().ok_or("--panel-style requires a value")?;
2387                panel_style =
2388                    Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2389            }
2390            "-w" | "--width" => {
2391                let value = iter.next().ok_or("--width requires a number")?;
2392                let columns: usize = value.parse().map_err(|_| {
2393                    // The common cause is a missing value, in which case this
2394                    // has just eaten the filename — say so rather than quoting
2395                    // the path back with debug escaping.
2396                    if std::path::Path::new(value).exists() {
2397                        format!("--width needs a number, but got the file '{value}' — a value is missing")
2398                    } else {
2399                        format!("invalid width '{value}'")
2400                    }
2401                })?;
2402                if columns == 0 {
2403                    return Err("--width must be at least 1".into());
2404                }
2405                width = Some(columns);
2406            }
2407            other if other.starts_with('-') && other != "-" => {
2408                return Err(format!("unknown option {other:?}"));
2409            }
2410            other
2411                if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2412            {
2413                ansi_word = other == "ansi";
2414                set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2415            }
2416            "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2417            other => resources.push(other.to_string()),
2418        }
2419    }
2420
2421    // `--format` makes automatic mode an input mode: with no RESOURCE it reads
2422    // stdin, as `--json` does, instead of showing the demo.
2423    if data.format.is_some() {
2424        if !matches!(mode, Mode::Auto | Mode::Inspect) {
2425            return Err(format!(
2426                "--format cannot be combined with --{}",
2427                mode_name(mode)
2428            ));
2429        }
2430        if mode == Mode::Auto && resources.is_empty() && !batch {
2431            resources.push("-".into());
2432        }
2433    }
2434    if let Some(flag) = data
2435        .inspect_only_option(mode == Mode::Capture)
2436        .filter(|_| mode != Mode::Inspect)
2437    {
2438        if flag == "--redact" {
2439            return Err("--redact only has an effect with --inspect or `rich capture`".into());
2440        }
2441        return Err(format!("{flag} only has an effect with --inspect"));
2442    }
2443    if let Some(flag) = data.transform_option() {
2444        if !transforms_apply(mode) {
2445            return Err(format!(
2446                "{flag} only has an effect on {}",
2447                transform_modes(flag)
2448            ));
2449        }
2450        data.check_transforms(mode == Mode::Inspect)?;
2451    }
2452    if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2453        return Err(format!("{flag} only has an effect with --diff"));
2454    }
2455    if let Some(flag) = tool_options
2456        .ansi_option()
2457        .filter(|_| mode != Mode::AnsiExplain)
2458    {
2459        return Err(format!("{flag} only has an effect with --ansi-explain"));
2460    }
2461    for (flag, commands) in viewer_options
2462        .given()
2463        .into_iter()
2464        .chain(graph_sources.given())
2465        .chain(chart.given())
2466        .chain(schema_options.given())
2467        .chain(profile_options.given())
2468    {
2469        if !commands.contains(&mode_name(mode)) {
2470            return Err(format!(
2471                "{flag} only has an effect with `rich {}`",
2472                commands.join("` or `rich ")
2473            ));
2474        }
2475    }
2476    if mode == Mode::Capture && resources.is_empty() {
2477        return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2478    }
2479    if mode == Mode::View && !paging_explicit {
2480        auto_pager = true;
2481    }
2482    data.validate()?;
2483
2484    if watch && (batch || pager || auto_pager) {
2485        return Err("--watch cannot be combined with --batch or paging".into());
2486    }
2487
2488    // `--gif`/`--diff` consume their whole resource list as one unit (a diff is
2489    // a pair), but a batch rewrites each item to a single resource — `run_diff`
2490    // would then index `resources[1]` and panic. Reject the combination.
2491    if batch {
2492        if mode.accepts_multiple_resources() {
2493            return Err(format!(
2494                "--batch cannot be combined with {}",
2495                if mode == Mode::Diff {
2496                    "--diff"
2497                } else {
2498                    "--gif"
2499                }
2500            ));
2501        }
2502        if resources.is_empty() {
2503            return Err("--batch needs at least one file, directory or glob".into());
2504        }
2505        // One pager per item would be N interactive pagers over one plan.
2506        if pager || auto_pager {
2507            return Err("--pager cannot be combined with --batch".into());
2508        }
2509    }
2510
2511    // Only `--gif` animates several resources at once; every other mode renders
2512    // exactly one.
2513    if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2514        return Err(
2515            "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2516        );
2517    }
2518    // `--conflicts` reads the merge conflicts left in one file; there is no
2519    // percentage of it to gate on.
2520    if mode == Mode::Diff && tool_options.conflicts() {
2521        if resources.len() != 1 {
2522            return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2523        }
2524        if diff_threshold.is_some() {
2525            return Err("--threshold cannot be combined with --conflicts".into());
2526        }
2527    }
2528    if mode == Mode::Image {
2529        if resources.is_empty() {
2530            return Err(
2531                "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2532            );
2533        }
2534        if image_mode == ImageMode::None {
2535            return Err(
2536                "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2537                    .into(),
2538            );
2539        }
2540    }
2541    let quantized = matches!(
2542        image_color.as_deref(),
2543        Some("ansi256" | "ansi16" | "grayscale")
2544    );
2545    if quantized && image_mode == ImageMode::Braille {
2546        return Err(format!(
2547            "--image-color {} has no effect with --image-mode braille, which is monochrome",
2548            image_color.as_deref().unwrap()
2549        ));
2550    }
2551    for (flag, value, neutral) in [
2552        ("--image-dither", image_dither.as_deref(), "none"),
2553        (
2554            "--image-color-distance",
2555            image_color_distance.as_deref(),
2556            "rgb",
2557        ),
2558    ] {
2559        if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2560            return Err(format!(
2561                "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2562            ));
2563        }
2564    }
2565    if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2566        return Err("--image-anchor requires --image-fit cover".into());
2567    }
2568    if (batch_paths.preserve_dirs
2569        || batch_paths.template.is_some()
2570        || batch_paths.input_root.is_some())
2571        && !batch
2572    {
2573        return Err("batch path options require --batch".into());
2574    }
2575    if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2576        return Err("--batch-preserve-dirs requires --batch-input-root".into());
2577    }
2578    if dry_run && !batch {
2579        return Err("--dry-run requires --batch".into());
2580    }
2581    if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2582        return Err("--jobs greater than 1 requires --batch with a file export".into());
2583    }
2584    // `native` sizes to the image itself; the others fill a rectangle.
2585    if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2586        return Err("--image-fit requires --height to define the target rectangle".into());
2587    }
2588    // `--gif` animates in place and the demo writes its own console, so neither
2589    // goes through the export path: both accepted -o/--export-svg, wrote no
2590    // file, and exited 0. Everywhere else a bad export path is a hard error, so
2591    // silence here reads as success.
2592    let effective_mode = if mode == Mode::Auto {
2593        detect_mode(resources.first().map(String::as_str))
2594    } else {
2595        mode
2596    };
2597    extensions.validate(
2598        effective_mode == Mode::Gif,
2599        effective_mode.allows_extensions()
2600            && !(mode == Mode::Auto && resources.is_empty())
2601            && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2602    )?;
2603    let exporting = export_html.is_some() || export_svg.is_some();
2604    if exporting {
2605        let unsupported = if effective_mode == Mode::Gif {
2606            Some("--gif")
2607        } else if mode == Mode::Auto && resources.is_empty() {
2608            Some("the capability demo")
2609        } else {
2610            None
2611        };
2612        if let Some(what) = unsupported {
2613            return Err(format!("--export-html/--export-svg cannot capture {what}"));
2614        }
2615    }
2616
2617    // A flag whose mode is absent is almost always a mistake, and silence is the
2618    // dangerous response: `--threshold` without `--diff` used to render the file
2619    // and exit 0, so a CI job that lost its `--diff` — a typo, a refactor, an
2620    // argument reordered — became a permanently green gate. That is the same
2621    // failure the NaN check closed, reached from the other side.
2622    let text_justify = [
2623        Justify::Left,
2624        Justify::Right,
2625        Justify::Center,
2626        Justify::Full,
2627    ]
2628    .into_iter()
2629    .zip(text_flags)
2630    .find_map(|(justify, given)| given.then_some(justify));
2631    // Automatic mode renders source as syntax, as upstream's does, so the
2632    // source options apply to it.
2633    let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2634    let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2635    let orphans = [
2636        (
2637            "--head/--tail",
2638            source.head.is_some() || source.tail.is_some(),
2639            "--syntax, --csv or --ipynb",
2640            source_mode || effective_mode == Mode::Csv,
2641        ),
2642        (
2643            "--infer",
2644            source.infer,
2645            "--csv",
2646            effective_mode == Mode::Csv,
2647        ),
2648        (
2649            "--line-numbers",
2650            source.line_numbers,
2651            "--syntax or --ipynb",
2652            source_mode,
2653        ),
2654        (
2655            "--guides",
2656            source.guides,
2657            "--syntax or --ipynb",
2658            source_mode,
2659        ),
2660        (
2661            "--lexer",
2662            source.lexer.is_some(),
2663            "--syntax, --ipynb or --rst",
2664            source_mode || effective_mode == Mode::Rst,
2665        ),
2666        (
2667            "--no-wrap",
2668            source.no_wrap,
2669            "--syntax, --ipynb, --print or --rule",
2670            source_mode || print_mode,
2671        ),
2672        (
2673            "--text-left/--text-right/--text-center/--text-full",
2674            text_justify.is_some(),
2675            "--print or --rule",
2676            print_mode,
2677        ),
2678        (
2679            "--rule-style",
2680            rule_style.is_some(),
2681            "--rule",
2682            effective_mode == Mode::Rule,
2683        ),
2684        (
2685            "--rule-char",
2686            rule_char.is_some(),
2687            "--rule",
2688            effective_mode == Mode::Rule,
2689        ),
2690        // The streaming modes write each record as it arrives, not through
2691        // the final print these shape.
2692        (
2693            "--soft",
2694            soft,
2695            "a mode other than --jsonl or --log",
2696            !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2697        ),
2698        (
2699            "--max-width",
2700            max_width.is_some(),
2701            "a mode other than --jsonl or --log",
2702            !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2703        ),
2704        (
2705            "--threshold",
2706            diff_threshold.is_some(),
2707            "--diff",
2708            mode == Mode::Diff,
2709        ),
2710        (
2711            "--image-mode",
2712            image_mode != ImageMode::Auto,
2713            "--diff/--image",
2714            mode == Mode::Diff || mode == Mode::Image,
2715        ),
2716        ("--height", height.is_some(), "--image", mode == Mode::Image),
2717        (
2718            "--image-rotate",
2719            image_rotate.is_some(),
2720            "--image",
2721            mode == Mode::Image,
2722        ),
2723        (
2724            "--image-flip-horizontal",
2725            image_flip_horizontal,
2726            "--image",
2727            mode == Mode::Image,
2728        ),
2729        (
2730            "--image-flip-vertical",
2731            image_flip_vertical,
2732            "--image",
2733            mode == Mode::Image,
2734        ),
2735        (
2736            "--image-grayscale",
2737            image_grayscale,
2738            "--image",
2739            mode == Mode::Image,
2740        ),
2741        (
2742            "--image-color",
2743            image_color.is_some(),
2744            "--image or --gif",
2745            mode == Mode::Image || effective_mode == Mode::Gif,
2746        ),
2747        (
2748            "--image-max-width",
2749            image_max_width.is_some(),
2750            "--image",
2751            mode == Mode::Image,
2752        ),
2753        (
2754            "--image-max-height",
2755            image_max_height.is_some(),
2756            "--image",
2757            mode == Mode::Image,
2758        ),
2759        (
2760            "--image-brightness",
2761            image_brightness.is_some(),
2762            "--image",
2763            mode == Mode::Image,
2764        ),
2765        (
2766            "--image-contrast",
2767            image_contrast.is_some(),
2768            "--image",
2769            mode == Mode::Image,
2770        ),
2771        (
2772            "--image-gamma",
2773            image_gamma.is_some(),
2774            "--image",
2775            mode == Mode::Image,
2776        ),
2777        (
2778            "--image-dither",
2779            image_dither.is_some(),
2780            "--image or --gif",
2781            mode == Mode::Image || effective_mode == Mode::Gif,
2782        ),
2783        (
2784            "--image-color-distance",
2785            image_color_distance.is_some(),
2786            "--image or --gif",
2787            mode == Mode::Image || effective_mode == Mode::Gif,
2788        ),
2789        (
2790            "--image-fit",
2791            image_fit.is_some(),
2792            "--image",
2793            mode == Mode::Image,
2794        ),
2795        (
2796            "--image-background",
2797            image_background.is_some(),
2798            "--image",
2799            mode == Mode::Image,
2800        ),
2801        (
2802            "--loop",
2803            loops.is_some(),
2804            "--gif",
2805            effective_mode == Mode::Gif,
2806        ),
2807        // `--title`/`--caption` are deliberately NOT here: upstream feeds them to
2808        // the CSV table as well as to the panel, so requiring `--panel` made
2809        // `rich --csv sales.csv --title Sales` — a documented upstream use —
2810        // impossible. `-S`/`-e` really do nothing without a panel.
2811        (
2812            "--panel-style",
2813            panel_style.is_some(),
2814            "--panel",
2815            panel.is_some(),
2816        ),
2817        (
2818            "--expand",
2819            expand,
2820            "--panel/--padding",
2821            panel.is_some() || padding.is_some(),
2822        ),
2823        ("--watch-interval", watch_interval != 1.0, "--watch", watch),
2824        ("--watch-cache", watch_cache, "--watch", watch),
2825        (
2826            "--watch-debounce",
2827            watch_debounce.is_some(),
2828            "--watch",
2829            watch,
2830        ),
2831        ("--watch-poll", watch_poll, "--watch", watch),
2832        (
2833            "--watch-exit-on-error",
2834            watch_exit_on_error,
2835            "--watch",
2836            watch,
2837        ),
2838    ];
2839    if let Some((flag, _, needs, _)) = orphans
2840        .iter()
2841        .find(|(_, given, _, mode_present)| *given && !*mode_present)
2842    {
2843        return Err(format!("{flag} only has an effect with {needs}"));
2844    }
2845
2846    // These decorate a single rendered resource; --diff composes its own report
2847    // and quietly dropped them, which reads as the flag having no effect.
2848    let demo = mode == Mode::Auto && resources.is_empty();
2849    if effective_mode.draws_directly() || demo {
2850        let mode_name = if effective_mode == Mode::Gif {
2851            "--gif"
2852        } else if effective_mode == Mode::Image {
2853            "--image"
2854        } else if effective_mode.streams_records() {
2855            mode_name(effective_mode)
2856        } else if demo {
2857            "the capability demo"
2858        } else {
2859            "--diff"
2860        };
2861        let unsupported = [
2862            (
2863                "--pager",
2864                (pager || auto_pager)
2865                    && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
2866            ),
2867            (
2868                "--export-html/--export-svg",
2869                (export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
2870            ),
2871            ("--width", demo && width.is_some()),
2872            ("--theme-style", demo && !theme_styles.is_empty()),
2873            ("--theme-file", demo && !theme_file_styles.is_empty()),
2874            (
2875                "--hyperlinks",
2876                hyperlinks && (effective_mode.streams_records() || demo),
2877            ),
2878            ("--panel", panel.is_some()),
2879            ("--padding", padding.is_some()),
2880            ("--title", title.is_some()),
2881            ("--caption", caption.is_some()),
2882            ("--style", style.is_some()),
2883            ("--panel-style", panel_style.is_some()),
2884            ("--expand", expand),
2885            ("--left/--center/--right", justify.is_some()),
2886        ];
2887        if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
2888            return Err(format!("{flag} cannot be combined with {mode_name}"));
2889        }
2890    }
2891    // `--watch` may follow several local files, one live region each; the
2892    // watcher itself checks that every one of them is a watchable file.
2893    if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
2894        return Err("only one resource may be given (except with --gif or --watch)".into());
2895    }
2896    // `view`, `schema` and the text diff are new here, with no upstream
2897    // output to keep: like `less`, they show terminal controls in what they
2898    // display instead of letting them act, unless `--no-sanitize` says
2899    // otherwise.
2900    let sanitize = sanitize.unwrap_or_else(|| {
2901        mode == Mode::View
2902            || mode == Mode::Schema
2903            || (mode == Mode::Diff
2904                && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
2905    });
2906    // `--print` and `--rule` show their resource as text.
2907    let resource = resources.first().map(|resource| {
2908        if print_mode {
2909            text_arg(resource)
2910        } else {
2911            resource.clone()
2912        }
2913    });
2914    let title = title.map(|title| text_arg(&title));
2915    let caption = caption.map(|caption| text_arg(&caption));
2916    let rule_char = rule_char.map(|characters| text_arg(&characters));
2917
2918    // An unknown highlighter or theme is a usage error, listing the choices.
2919    code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
2920    Ok(Some(Cli {
2921        mode,
2922        resource,
2923        resources,
2924        loops,
2925        diff_threshold,
2926        image_mode,
2927        image_fit,
2928        image_anchor,
2929        image_background,
2930        image_color,
2931        image_dither,
2932        image_color_distance,
2933        image_rotate,
2934        image_flip_horizontal,
2935        image_flip_vertical,
2936        image_grayscale,
2937        image_max_width,
2938        image_max_height,
2939        image_brightness,
2940        image_contrast,
2941        image_gamma,
2942        log_presentation,
2943        mermaid_backend,
2944        dot_backend,
2945        graph_sources,
2946        chart,
2947        schema: schema_options,
2948        profile: profile_options,
2949        highlighter,
2950        code_theme,
2951        plugins,
2952        micro_project,
2953        theme_styles,
2954        theme_file_styles,
2955        height,
2956        extensions,
2957        width,
2958        justify,
2959        text_justify,
2960        emoji,
2961        soft,
2962        max_width,
2963        rule_style,
2964        rule_char,
2965        force_terminal,
2966        source,
2967        no_color,
2968        export_html,
2969        export_svg,
2970        panel,
2971        padding,
2972        title,
2973        caption,
2974        panel_style,
2975        style,
2976        expand,
2977        pager,
2978        auto_pager,
2979        hyperlinks,
2980        sanitize,
2981        report_format,
2982        watch,
2983        watch_interval,
2984        watch_cache,
2985        watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
2986        watch_poll,
2987        watch_exit_on_error,
2988        batch,
2989        progress,
2990        continue_on_error,
2991        jobs,
2992        dry_run,
2993        batch_paths,
2994        worker_args: worker_args(args),
2995        overwrite,
2996        collision,
2997        data,
2998        tools: tool_options,
2999        viewers: viewer_options,
3000    }))
3001}
3002
3003/// At most `max` of the resource's bytes from `offset` on, unconverted, and
3004/// whether more follow: a URL is fetched as text, stdin and files are read
3005/// raw so binary input survives. With `--encoding` the text is decoded first,
3006/// as every other mode does (reading at most
3007/// [`controls::INSPECT_LIMIT`] bytes).
3008///
3009/// A file is read from `offset` (seeking where it can) and no further than
3010/// the window needs, so `rich hex /dev/urandom --length 16` ends and a sparse
3011/// gigabyte costs only the bytes shown.
3012fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3013    use std::io::{Seek, SeekFrom};
3014    let resource = cli.resource.as_deref();
3015    let name = resource.unwrap_or("<stdin>");
3016    let window = |bytes: Vec<u8>| {
3017        let start = usize::try_from(offset)
3018            .unwrap_or(usize::MAX)
3019            .min(bytes.len());
3020        let mut rest = bytes[start..].to_vec();
3021        let more = rest.len() as u64 > max;
3022        rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3023        (rest, more)
3024    };
3025    if let Some(url) = resource.filter(|r| is_url(r)) {
3026        return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3027    }
3028    if cli.extensions.encoding.is_some() {
3029        return read_resource_limited(
3030            resource,
3031            cli.extensions.encoding,
3032            Some(controls::INSPECT_LIMIT),
3033        )
3034        .map(|text| window(text.into_bytes()))
3035        .map_err(|err| format!("cannot read {name}: {err}"));
3036    }
3037    let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3038    // One byte past the window says whether more follow.
3039    let take = max.saturating_add(1);
3040    let mut bytes = Vec::new();
3041    match resource.filter(|r| *r != "-") {
3042        Some(path) if fs_path(path).is_dir() => {
3043            return Err(format!("cannot read {path}: it is a directory"));
3044        }
3045        Some(path) => {
3046            let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3047            // A pipe or FIFO cannot seek: skip by reading instead.
3048            if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3049                std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3050                    .map_err(error)?;
3051            }
3052            file.take(take).read_to_end(&mut bytes).map_err(error)?;
3053        }
3054        None => {
3055            let mut stdin = std::io::stdin().lock();
3056            std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3057            stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3058        }
3059    }
3060    let more = bytes.len() as u64 > max;
3061    bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3062    Ok((bytes, more))
3063}
3064
3065/// Read up to `limit` bytes (all of them for `None`); more is an error.
3066fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3067    let mut bytes = Vec::new();
3068    match limit {
3069        None => {
3070            let mut reader = reader;
3071            reader.read_to_end(&mut bytes)?;
3072        }
3073        Some(limit) => {
3074            reader
3075                .take(limit.saturating_add(1))
3076                .read_to_end(&mut bytes)?;
3077            if bytes.len() as u64 > limit {
3078                return Err(std::io::Error::other(format!(
3079                    "larger than {}, the most this command reads",
3080                    controls::size(limit)
3081                )));
3082            }
3083        }
3084    }
3085    Ok(bytes)
3086}
3087
3088/// Read a resource: `-` (or `None`) means stdin, otherwise a file path.
3089///
3090/// A **file** is decoded with replacement, not rejected: upstream opens it as
3091/// `open(path, "rt", encoding="utf8", errors="replace")`, so a cp1252 export —
3092/// what Excel writes on a Windows box by default — renders with `�` where the
3093/// odd byte was. We refused the whole file with exit 1, which is the one
3094/// outcome that makes the tool useless for exactly the files people reach for
3095/// it with.
3096///
3097/// **Stdin stays strict.** Upstream reads it with `sys.stdin.read()`, whose
3098/// decode error escapes into rich-cli's `except Exception` and exits non-zero;
3099/// there is no `errors="replace"` on that path.
3100fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3101    read_resource_limited(resource, encoding, None)
3102}
3103
3104/// [`read_resource`], reading at most `limit` bytes (all of them for `None`):
3105/// more is an error. The upstream modes read without a limit, as upstream
3106/// does.
3107fn read_resource_limited(
3108    resource: Option<&str>,
3109    encoding: Option<Encoding>,
3110    limit: Option<u64>,
3111) -> std::io::Result<String> {
3112    let content = match resource {
3113        Some(path) if path != "-" => {
3114            let file = &fs_path(path);
3115            // A directory reaches the reader as "Access is denied" on Windows,
3116            // which sends the reader hunting for a permissions problem.
3117            if file.is_dir() {
3118                return Err(std::io::Error::new(
3119                    std::io::ErrorKind::InvalidInput,
3120                    "is a directory, not a file",
3121                ));
3122            }
3123            let bytes = match limit {
3124                None => std::fs::read(file)?,
3125                Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3126            };
3127            if let Some(encoding) = encoding {
3128                return encoding.decode(&bytes).map(normalize_newlines);
3129            }
3130            if has_utf16_bom(&bytes) {
3131                eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
3132            }
3133            match String::from_utf8(bytes) {
3134                Ok(text) => text,
3135                // An image is not text in any encoding, and lossily decoding one
3136                // fills the terminal with thousands of replacement characters
3137                // and no clue. Keep the diagnostic for those; decode everything
3138                // else, which is what `errors="replace"` is actually for.
3139                Err(_) if looks_like_image(path) => {
3140                    return Err(std::io::Error::new(
3141                        std::io::ErrorKind::InvalidData,
3142                        "looks like an image; use `rich --image <path>` to view it, or \
3143                         `rich --diff <before> <after>` to compare two images",
3144                    ))
3145                }
3146                Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3147            }
3148        }
3149        _ => {
3150            if std::io::stdin().is_terminal() {
3151                let eof = if cfg!(windows) {
3152                    "Ctrl-Z then Enter"
3153                } else {
3154                    "Ctrl-D"
3155                };
3156                eprintln!("rich: reading stdin; finish input with {eof}");
3157            }
3158            let buffer = read_limited(std::io::stdin(), limit)?;
3159            decode_strict(buffer, encoding)?
3160        }
3161    };
3162    Ok(normalize_newlines(if encoding.is_some() {
3163        content
3164    } else {
3165        strip_bom(content)
3166    }))
3167}
3168
3169fn sanitize_json_value(value: &mut serde_json::Value) {
3170    match value {
3171        serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3172        serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3173        serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3174        _ => {}
3175    }
3176}
3177
3178fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3179    let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3180    sanitize_json_value(&mut value);
3181    serde_json::to_string(&value)
3182}
3183
3184/// Strict stdin/URL input; the default file path separately retains replacement.
3185fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3186    if let Some(encoding) = encoding {
3187        return encoding.decode(&bytes);
3188    }
3189    String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3190        std::io::ErrorKind::InvalidData,
3191        if has_utf16_bom(err.as_bytes()) {
3192            "input has a UTF-16 BOM; use --encoding utf-16"
3193        } else {
3194            "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3195        },
3196    ))
3197}
3198
3199/// Translate CRLF and lone CR to LF, as Python's universal newlines do.
3200///
3201/// Upstream reads text files in universal-newline mode, so it **never sees a
3202/// CR**; `std::fs::read_to_string` hands them straight through. That gap made
3203/// `--syntax` on a CRLF file render as a blank rectangle: each line was emitted
3204/// as `code` + CR + padding, so the CR returned the cursor to column 0 and the
3205/// padding overwrote the code. Exit 0, nothing visible, and piping hid it
3206/// because the bytes were all present — only a terminal acts on the CR.
3207fn normalize_newlines(text: String) -> String {
3208    const CR: char = '\r';
3209    const LF: char = '\n';
3210    if !text.contains(CR) {
3211        return text;
3212    }
3213    let mut out = String::with_capacity(text.len());
3214    let mut chars = text.chars().peekable();
3215    while let Some(c) = chars.next() {
3216        if c == CR {
3217            // CRLF and a lone CR both collapse to a single LF, matching
3218            // Python's universal-newline translation.
3219            if chars.peek() == Some(&LF) {
3220                chars.next();
3221            }
3222            out.push(LF);
3223        } else {
3224            out.push(c);
3225        }
3226    }
3227    out
3228}
3229
3230/// Drop a leading UTF-8 byte-order mark.
3231///
3232/// Windows editors write one by default and it is invisible: it made valid JSON
3233/// fail to parse at "column 1", and Markdown render its first heading as
3234/// literal text at exit 0 — in both cases with nothing pointing at the cause.
3235/// Whether a path's extension names an image format `--diff`/`--image` could
3236/// read.
3237///
3238/// Used only to improve an error message, so a false negative costs nothing.
3239fn looks_like_image(path: &str) -> bool {
3240    std::path::Path::new(path)
3241        .extension()
3242        .and_then(|e| e.to_str())
3243        .map(|e| e.to_ascii_lowercase())
3244        .is_some_and(|e| {
3245            matches!(
3246                e.as_str(),
3247                "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3248            )
3249        })
3250}
3251
3252fn strip_bom(text: String) -> String {
3253    match text.strip_prefix('\u{feff}') {
3254        Some(rest) => rest.to_string(),
3255        None => text,
3256    }
3257}
3258
3259/// Whether `resource` is an `http(s)` URL (rich-cli's `rich <url>`). The scheme
3260/// is matched case-insensitively, as RFC 3986 specifies.
3261fn is_url(resource: &str) -> bool {
3262    let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3263    head.starts_with("http://") || head.starts_with("https://")
3264}
3265
3266/// The lowercased file extension of a resource, taken from its basename (the
3267/// last path segment) with any `?query`/`#fragment` stripped — so it works for
3268/// URLs (`…/main.rs?raw=1` → `rs`) as well as plain paths.
3269fn resource_ext(resource: &str) -> Option<String> {
3270    let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3271    let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3272    basename
3273        .rsplit_once('.')
3274        .map(|(_, ext)| ext.to_ascii_lowercase())
3275        .filter(|ext| !ext.is_empty())
3276}
3277
3278/// The maximum *decoded* response body we will hold in memory, in bytes.
3279/// Exceeding it is a hard error (we never render a truncated resource).
3280#[cfg(feature = "fetch")]
3281const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3282
3283/// Fetch a URL over HTTP(S), returning `(body, content_type)`. Port of
3284/// rich-cli's `requests.get`, with two deliberate safety bounds upstream lacks:
3285/// a 30-second global timeout and [`MAX_BODY_BYTES`].
3286///
3287/// Like `requests.get`, a non-2xx status is **not** an error — the body is
3288/// returned so an error page still renders. TLS verification is on (rustls with
3289/// bundled webpki roots).
3290#[cfg(feature = "fetch")]
3291fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3292    use std::time::Duration;
3293    let mut response = ureq::get(url)
3294        .config()
3295        .timeout_global(Some(Duration::from_secs(30)))
3296        // Match `requests.get`: don't turn 4xx/5xx into an error, so the
3297        // response body (often a useful error page) is rendered instead.
3298        .http_status_as_error(false)
3299        .build()
3300        .call()
3301        .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3302    let content_type = response
3303        .headers()
3304        .get("content-type")
3305        .and_then(|value| value.to_str().ok())
3306        .map(str::to_string);
3307
3308    // Bound the *decoded* body: `.reader()` yields post-decompression bytes, so
3309    // capping it here (rather than via ureq's `limit()`, whose LimitReader sits
3310    // *under* the gzip decoder and would only bound compressed bytes) also
3311    // defuses a `Content-Encoding: gzip` bomb. Read one byte past the limit so
3312    // an oversized body is detectable rather than silently truncated.
3313    let mut buffer = Vec::new();
3314    response
3315        .body_mut()
3316        .with_config()
3317        .reader()
3318        .take(MAX_BODY_BYTES + 1)
3319        .read_to_end(&mut buffer)
3320        .map_err(|err| format!("cannot read {url}: {err}"))?;
3321    if buffer.len() as u64 > MAX_BODY_BYTES {
3322        return Err(format!(
3323            "{url} exceeds the {} MiB limit",
3324            MAX_BODY_BYTES / (1024 * 1024)
3325        ));
3326    }
3327    let body =
3328        decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3329    Ok((body, content_type))
3330}
3331
3332/// Stub used when the crate is built without the `fetch` feature.
3333#[cfg(not(feature = "fetch"))]
3334fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3335    Err(format!(
3336        "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3337    ))
3338}
3339
3340/// The high-level render mode implied by a response `Content-Type` (the MIME
3341/// type, ignoring any `; charset=…`). Used only when neither a flag nor the URL
3342/// extension already picked a mode.
3343fn content_type_mode(content_type: &str) -> Option<Mode> {
3344    let mime = mime_of(content_type);
3345    match mime.as_str() {
3346        "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3347        "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3348        "application/json" | "text/json" => Some(Mode::Json),
3349        "text/csv" => Some(Mode::Csv),
3350        _ => None,
3351    }
3352}
3353
3354/// A syntect lexer name for a response `Content-Type`, for syntax-highlighting a
3355/// fetched resource that has no informative extension. A small common subset of
3356/// upstream's Pygments MIME table.
3357fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3358    let mime = mime_of(content_type);
3359    Some(match mime.as_str() {
3360        "text/html" | "application/xhtml+xml" => "html",
3361        "text/css" => "css",
3362        "application/javascript" | "text/javascript" => "javascript",
3363        "application/xml" | "text/xml" => "xml",
3364        "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3365        "text/x-rust" => "rust",
3366        "text/x-c" | "text/x-csrc" => "c",
3367        "application/x-sh" | "text/x-shellscript" => "bash",
3368        "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3369        "application/toml" | "text/x-toml" => "toml",
3370        _ => return None,
3371    })
3372}
3373
3374/// Whether an extension names no particular language, so a `Content-Type` that
3375/// does should win when picking a syntax lexer.
3376fn is_uninformative_ext(ext: &str) -> bool {
3377    matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3378}
3379
3380/// The bare MIME type from a `Content-Type` header (lowercased, `; charset=…`
3381/// and surrounding whitespace stripped).
3382fn mime_of(content_type: &str) -> String {
3383    content_type
3384        .split(';')
3385        .next()
3386        .unwrap_or("")
3387        .trim()
3388        .to_ascii_lowercase()
3389}
3390
3391/// Resolve `Mode::Auto` to a concrete mode from the resource's file extension.
3392/// Whether `--filter` and `--highlight` apply in `mode`: plain text,
3393/// `--print`, `--syntax` and `--inspect`.
3394fn transforms_apply(mode: Mode) -> bool {
3395    matches!(
3396        mode,
3397        Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3398    )
3399}
3400
3401/// The usage error for a transform option `mode` does not take: any of them
3402/// outside [`transforms_apply`], and `--transform` (text only) with
3403/// `--inspect`.
3404fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3405    if let Some(flag) = cli
3406        .data
3407        .transform_option()
3408        .filter(|_| !transforms_apply(mode))
3409    {
3410        return Some(transforms_misplaced(flag, mode));
3411    }
3412    (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3413        .then(|| cli.data.check_transforms(true).unwrap_err())
3414}
3415
3416fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3417    format!(
3418        "{flag} only has an effect on {}, not {}",
3419        transform_modes(flag),
3420        mode_name(mode)
3421    )
3422}
3423
3424/// Where a transform option applies: `--transform` rewrites text only.
3425fn transform_modes(flag: &str) -> &'static str {
3426    if flag == "--transform" {
3427        "text, --print or --syntax"
3428    } else {
3429        "text, --print, --syntax or --inspect"
3430    }
3431}
3432
3433fn detect_mode(resource: Option<&str>) -> Mode {
3434    match resource.and_then(resource_ext).as_deref() {
3435        Some("md") | Some("markdown") => Mode::Markdown,
3436        Some("json") => Mode::Json,
3437        Some("csv") | Some("tsv") => Mode::Csv,
3438        Some("ipynb") => Mode::Ipynb,
3439        Some("rst") => Mode::Rst,
3440        Some("gif") => Mode::Gif,
3441        Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3442        Some("dot") | Some("gv") => Mode::Dot,
3443        // Anything the table above does not divert is source code, and upstream
3444        // highlights it: `rich main.rs` is syntax-highlighted with no flag at
3445        // all. We printed it raw instead, so `rich hello.py` produced no
3446        // highlighting and no styling in an export — while `docs/cli.md` line 12
3447        // advertised the upstream behaviour and `--help` documented ours. The
3448        // two contradicted each other; this resolves them the way the docs (and
3449        // upstream) say.
3450        Some(_) => Mode::Syntax,
3451        // No extension to go on (including stdin): stay in Auto and let the
3452        // caller decide, rather than guessing a lexer for something that may not
3453        // be source at all.
3454        None => Mode::Auto,
3455    }
3456}
3457
3458/// Force a renderable to a fixed width, wherever on the screen it lands. Port
3459/// of rich-cli's `ForceWidth`.
3460///
3461/// `--width N` used to shrink the *console* to N, which also moved every
3462/// alignment inside N columns: `rich -w 20 --center` centred within 20 rather
3463/// than within the terminal, and an `--export-svg` frame came out N wide.
3464/// Upstream leaves the console alone and narrows only the renderable.
3465struct ForceWidth {
3466    child: Box<dyn Renderable>,
3467    width: usize,
3468}
3469
3470impl Renderable for ForceWidth {
3471    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3472        self.child
3473            .rich_render(console, &options.update_width(self.width))
3474    }
3475
3476    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3477        Measurement::new(self.width, self.width)
3478    }
3479}
3480
3481/// Position a renderable within the available width. Port of the horizontal
3482/// axis of `rich.align.Align`, which is what `console.print(justify=…)` wraps
3483/// every renderable in.
3484///
3485/// The core crate's [`Align`] follows upstream: it constrains the child to its
3486/// measured width, `set_shape`s the block, then pads it. But in this port
3487/// `Table`, `Json` and friends measure as the whole console (see
3488/// `measure_rendered`), so core `Align` would not move them. This wrapper takes
3489/// the width to constrain to from the caller instead.
3490struct Aligned {
3491    child: Box<dyn Renderable>,
3492    /// The width to render the child *within* — what upstream's `Align` reads
3493    /// out of `console.measure(renderable)` and hands to `Constrain`. The block
3494    /// it then pads is measured from the output, not from this: a `--csv` table
3495    /// given `-w 40` still renders at its own 23 columns, and it is 23 that gets
3496    /// centred.
3497    width: usize,
3498    justify: Justify,
3499}
3500
3501impl Renderable for Aligned {
3502    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3503        let width = self.width.clamp(1, options.max_width);
3504        // The child renders with the console's own justify, not this alignment.
3505        // Upstream puts `print(justify=…)` into the render options, but rich-cli
3506        // builds its `--print`/`--rule` text with an explicit `justify="default"`
3507        // — a truthy string in Python — which blocks the inheritance, so
3508        // `-w 9 --center -p mid` centres a three-cell block within the terminal
3509        // rather than centring "mid" inside nine columns first. Every other
3510        // renderable this CLI builds (`Table`, `Json`, `Syntax`, `Markdown`)
3511        // ignores `options.justify` in this port, so there is nothing else for
3512        // the inheritance to reach.
3513        let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3514        let widths: Vec<usize> = lines
3515            .iter()
3516            .map(|line| line.iter().map(Segment::cell_length).sum())
3517            .collect();
3518        // `Segment.get_shape` then `Segment.set_shape`: square the block off at
3519        // the widest line it actually produced, so a multi-line child moves as
3520        // one block instead of each line drifting to its own width.
3521        let shape = widths.iter().copied().max().unwrap_or(0);
3522
3523        let excess = options.max_width.saturating_sub(shape);
3524        let (left, right) = match self.justify {
3525            Justify::Right => (excess, 0),
3526            Justify::Center => (excess / 2, excess - excess / 2),
3527            _ => (0, excess),
3528        };
3529        let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3530
3531        let mut segments = Vec::new();
3532        let last = lines.len().saturating_sub(1);
3533        for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3534            if left > 0 {
3535                segments.push(blank(left));
3536            }
3537            segments.extend(line);
3538            let pad = shape - line_width + right;
3539            if pad > 0 {
3540                segments.push(blank(pad));
3541            }
3542            if index != last {
3543                segments.push(Segment::line());
3544            }
3545        }
3546        segments
3547    }
3548}
3549
3550/// The width a renderable actually occupies, found by rendering it.
3551///
3552/// `Table`, `Json` and friends inherit `(max_width, max_width)` from the
3553/// `Renderable` default in the core crate, so asking them to measure themselves
3554/// always answers "the whole console" and a fitted panel would not shrink at
3555/// all. Neither pads its output to the space it is given, though, so the widest
3556/// rendered line *is* upstream's `Measurement.get(…).maximum`, clamped to the
3557/// console — which is what a fitted `Panel` needs.
3558///
3559/// Not usable for `Syntax`, whose lines *are* padded out to the full width by
3560/// its background: that one is measured from the source instead.
3561fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3562    console
3563        .render_lines(renderable, &console.options(), false)
3564        .iter()
3565        .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3566        .max()
3567        .unwrap_or(0)
3568}
3569
3570/// Only labels consumed by a panel or CSV table should be parsed. Other modes
3571/// ignore these options upstream, including malformed unused markup.
3572/// Whether something in this run expanded `:micro:` tokens whose
3573/// placeholders are still to be drawn.
3574fn micro_pending() -> bool {
3575    #[cfg(feature = "art")]
3576    return micro::drawing_pending();
3577    #[cfg(not(feature = "art"))]
3578    false
3579}
3580
3581fn validate_labels(cli: &Cli) -> Result<(), String> {
3582    for label in cli.title.iter().chain(cli.caption.iter()) {
3583        Text::from_markup(label).map_err(|err| err.to_string())?;
3584    }
3585    Ok(())
3586}
3587
3588/// Apply upstream's decorator chain — padding, panel, style, fixed width,
3589/// alignment — and print the result. Port of the tail of rich-cli's `main`.
3590///
3591/// `fit` is the width the renderable measures at; `None` means "fills whatever
3592/// it is given", which is what a `Markdown` reports, so nothing shrinks to it.
3593fn decorate_and_emit(
3594    cli: &Cli,
3595    console: &Console,
3596    export: &Export,
3597    renderable: Box<dyn Renderable>,
3598    fit: Option<usize>,
3599) -> ExitCode {
3600    decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3601}
3602
3603/// [`decorate_and_emit`], with `finish` turning the print and export result
3604/// into the exit code (and its report).
3605fn decorate_and_emit_with(
3606    cli: &Cli,
3607    console: &Console,
3608    export: &Export,
3609    renderable: Box<dyn Renderable>,
3610    fit: Option<usize>,
3611    finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3612) -> ExitCode {
3613    if cli.panel.is_some() {
3614        if let Err(err) = validate_labels(cli) {
3615            return fail(cli, ExitClass::Data, err);
3616        }
3617    }
3618    let max_width = console.width();
3619    // `if width > 0: expand = True` — a fixed width is a width to fill.
3620    let expand = cli.expand || cli.width.is_some();
3621    let mut renderable = renderable;
3622    let mut fit = fit;
3623
3624    // This port's `Padding` and `Panel` always fill the width they are given, so
3625    // a *fitted* box is a `Constrain` around one. Upstream instead passes
3626    // `expand=False` and lets the box measure its own child — the same width,
3627    // reached from the outside, which is the only way that works while the core
3628    // crate's `Table`/`Syntax`/`Json` still inherit the default measurement.
3629    if let Some((top, right, bottom, left)) = cli.padding {
3630        let padded: Box<dyn Renderable> =
3631            Box::new(Padding::new(renderable, (top, right, bottom, left)));
3632        // `Padding.__rich_measure__`: the child plus the horizontal padding,
3633        // capped at the available width.
3634        fit = fit.map(|width| (width + left + right).min(max_width));
3635        renderable = if expand {
3636            padded
3637        } else {
3638            Box::new(Constrain::new(padded, fit))
3639        };
3640    }
3641
3642    if let Some(box_set) = cli.panel {
3643        let mut panel = Panel::new(renderable).box_set(box_set);
3644        // `:micro:name:` in a label expands where its `:emoji:` codes do:
3645        // always, as `Text.from_markup` replaces them.
3646        #[cfg(feature = "art")]
3647        let micro_label = |label: &Option<String>| {
3648            label
3649                .as_deref()
3650                .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3651        };
3652        #[cfg(not(feature = "art"))]
3653        let micro_label = |_: &Option<String>| None::<Text>;
3654        let micro_title = micro_label(&cli.title);
3655        if let Some(title) = micro_title.clone() {
3656            panel = panel.title_as_text(title);
3657        } else if let Some(title) = cli.title.clone() {
3658            panel = panel.title(title);
3659        }
3660        if let Some(caption) = micro_label(&cli.caption) {
3661            panel = panel.subtitle_as_text(caption);
3662        } else if let Some(caption) = cli.caption.clone() {
3663            panel = panel.subtitle(caption);
3664        }
3665        if let Some(style) = cli.panel_style.clone() {
3666            panel = panel.border_style(style);
3667        }
3668        // `Panel.__rich_measure__`: the wider of the child (measured in
3669        // `max_width - 4`) and the title, plus a border and a pad on each side.
3670        // The *subtitle* is deliberately absent — upstream does not measure it,
3671        // so a long `--caption` is truncated by the border rather than widening
3672        // the panel.
3673        //
3674        // Panel parses markup, normalizes newlines/tabs, and pads the label by
3675        // one cell on either side. Measure visible text with the same rules.
3676        let inner = max_width.saturating_sub(4);
3677        let child = fit.unwrap_or(inner).min(inner);
3678        let title_width = cli
3679            .title
3680            .as_deref()
3681            .map(|title| {
3682                // `Text.from_markup(title)` replaces emoji codes whatever the
3683                // console's `emoji`, so the title draws (and measures) them.
3684                let mut title = match &micro_title {
3685                    Some(title) => {
3686                        let mut flat = title.blank_copy();
3687                        flat.append(&title.plain().replace('\n', " "), None);
3688                        flat
3689                    }
3690                    None => {
3691                        let title = rich::emoji::replace(title);
3692                        Text::from_markup(&title.replace('\n', " "))
3693                            .expect("title markup validated before rendering")
3694                    }
3695                };
3696                title.expand_tabs(8);
3697                cell_len(title.plain()) + 2
3698            })
3699            .unwrap_or(0);
3700        fit = Some(child.max(title_width) + 4);
3701        let panel: Box<dyn Renderable> = Box::new(panel);
3702        renderable = if expand {
3703            panel
3704        } else {
3705            Box::new(Constrain::new(panel, fit))
3706        };
3707    }
3708
3709    // `-s/--style` lays a style under everything, outside the panel — upstream's
3710    // `Styled(renderable, text_style)`. It does not change any width.
3711    if let Some(style) = cli.style.clone() {
3712        renderable = Box::new(Styled::new(renderable, style));
3713    }
3714
3715    // `if width > 0 and not pager` — the pager lays out at its own width.
3716    if let Some(width) = cli.width.filter(|_| !cli.pager) {
3717        renderable = Box::new(ForceWidth {
3718            child: renderable,
3719            width,
3720        });
3721        fit = Some(width);
3722    }
3723
3724    if let Some(justify) = cli.justify {
3725        renderable = Box::new(Aligned {
3726            child: renderable,
3727            width: fit.unwrap_or(max_width),
3728            justify,
3729        });
3730    }
3731
3732    // Micro assets are drawn only when the output goes to the terminal alone:
3733    // an export, the pager or a watch would record (or repaint over) Kitty's
3734    // placeholder cells or the blank cells under an image, so they (and the
3735    // terminal with them) keep the fallback.
3736    #[cfg(feature = "art")]
3737    if let Some(registry) =
3738        micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
3739    {
3740        renderable = micro::drawn(console, registry, renderable);
3741    }
3742
3743    // Upstream's final `console.print(renderable, width=max_width or None,
3744    // soft_wrap=soft)`.
3745    let mut options = console.options();
3746    if let Some(max_width) = cli.max_width {
3747        options.max_width = max_width.min(options.max_width);
3748    }
3749    let shown = if cli.soft {
3750        let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
3751        if !segments.is_empty() {
3752            segments.push(Segment::line());
3753        }
3754        emit_segments(console, export, segments)
3755    } else {
3756        emit(console, export, |c| {
3757            c.print_with(renderable.as_ref(), &options)
3758        })
3759    };
3760    finish(cli, shown)
3761}
3762
3763/// What `console.print(renderable, soft_wrap=True)` writes: nothing wraps
3764/// (`no_wrap`, `overflow="ignore"`) and, with `crop=False`, no line is cut at
3765/// the console width, so the terminal wraps it. Core's print always crops,
3766/// so this follows its print path (a printed text is joined, a renderable
3767/// that asks is fitted to its measurement) and stops short of the crop.
3768fn soft_wrap_segments(
3769    console: &Console,
3770    renderable: &dyn Renderable,
3771    options: &ConsoleOptions,
3772) -> Vec<Segment> {
3773    let mut options = options.clone();
3774    options.no_wrap.get_or_insert(true);
3775    options
3776        .overflow
3777        .get_or_insert(rich::console::Overflow::Ignore);
3778    let joined = renderable.printed_text();
3779    let renderable: &dyn Renderable = match &joined {
3780        Some(text) => text,
3781        None => renderable,
3782    };
3783    if renderable.fit_to_measurement() {
3784        let measurement = renderable.measure(console, &options);
3785        options.max_width = measurement.maximum.min(options.max_width).max(1);
3786    }
3787    console.render(renderable, Some(&options))
3788}
3789
3790fn run_cli(cli: Cli) -> ExitCode {
3791    if cli.watch {
3792        return run_watch(cli);
3793    }
3794    run_once(cli)
3795}
3796
3797fn run_watch(mut cli: Cli) -> ExitCode {
3798    let Some(resource) = cli.resource.clone() else {
3799        return fail(
3800            &cli,
3801            ExitClass::Usage,
3802            "--watch requires a file path or URL",
3803        );
3804    };
3805    let several = cli.resources.len() > 1;
3806    for resource in &cli.resources {
3807        if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
3808            return fail(
3809                &cli,
3810                ExitClass::Usage,
3811                "--watch requires a local file or URL, not stdin or literal markup",
3812            );
3813        }
3814        if several && is_url(resource) {
3815            return fail(
3816                &cli,
3817                ExitClass::Usage,
3818                "--watch with several resources requires local files; watch a URL on its own",
3819            );
3820        }
3821        let effective_mode = if cli.mode == Mode::Auto {
3822            detect_mode(Some(resource))
3823        } else {
3824            cli.mode
3825        };
3826        if effective_mode.draws_directly() || effective_mode == Mode::Rule {
3827            return fail(
3828                &cli,
3829                ExitClass::Usage,
3830                "--watch requires a resource-backed render mode",
3831            );
3832        }
3833    }
3834    if several && (cli.export_html.is_some() || cli.export_svg.is_some()) {
3835        return fail(
3836            &cli,
3837            ExitClass::Usage,
3838            "--export-html/--export-svg cannot be combined with watching several files",
3839        );
3840    }
3841    // Redirected output must be a finite, deterministic command. This also
3842    // keeps `rich --watch file > snapshot.txt` from hanging in a pipeline.
3843    // Several files render once each, in order, exactly as separate runs.
3844    if !std::io::stdout().is_terminal() {
3845        cli.watch = false;
3846        if !several {
3847            return run_once(cli);
3848        }
3849        let mut first_failure = None;
3850        for resource in cli.resources.clone() {
3851            let mut single = cli.clone();
3852            single.resource = Some(resource.clone());
3853            single.resources = vec![resource];
3854            let status = run_once(single);
3855            if status != ExitClass::Success.exit_code() && first_failure.is_none() {
3856                first_failure = Some(status);
3857            }
3858        }
3859        return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
3860    }
3861    // From here on the output repaints: micro assets keep their fallback.
3862    watch::start_watching();
3863    let resource = resource.as_str();
3864    if !is_url(resource) {
3865        return watch::watch_files(cli);
3866    }
3867    // Only URLs remain: they keep the polling-only loop below.
3868    if is_url(resource) {
3869        #[cfg(not(feature = "fetch"))]
3870        {
3871            cli.watch = false;
3872            return run_once(cli);
3873        }
3874    }
3875
3876    let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
3877    let mut previous = None;
3878    loop {
3879        #[cfg(feature = "fetch")]
3880        let cached = if is_url(resource) && cli.watch_cache {
3881            Some(fetch_url(resource, cli.extensions.encoding))
3882        } else {
3883            None
3884        };
3885        #[cfg(not(feature = "fetch"))]
3886        let cached: Option<Result<(String, Option<String>), String>> = None;
3887        let fingerprint = match cached.as_ref() {
3888            Some(Ok((body, content_type))) => {
3889                use std::hash::{Hash, Hasher};
3890                let mut hasher = std::collections::hash_map::DefaultHasher::new();
3891                body.hash(&mut hasher);
3892                content_type.hash(&mut hasher);
3893                format!("url:{:x}", hasher.finish())
3894            }
3895            Some(Err(error)) => format!("url-error:{error}"),
3896            None => watch_fingerprint(resource, false, cli.extensions.encoding),
3897        };
3898        let changed = previous.as_ref() != Some(&fingerprint);
3899        if changed || (is_url(resource) && !cli.watch_cache) {
3900            previous = Some(fingerprint);
3901            // Each watch frame owns the viewport; clear stale rows when a
3902            // replacement is shorter, then start the new frame at the top.
3903            let console = Console::new();
3904            console.clear();
3905            console.control(&Control::home());
3906            let _ = std::io::stdout().flush();
3907            let mut iteration = cli.clone();
3908            iteration.watch = false;
3909            let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
3910            if status != ExitClass::Success.exit_code() {
3911                // A temporary disappearance or parse failure is a frame error,
3912                // not a reason to abandon a watch that may recover.
3913                eprintln!("rich: watch will retry after the next change");
3914            }
3915        }
3916        std::thread::sleep(interval);
3917    }
3918}
3919
3920fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
3921    if is_url(resource) {
3922        #[cfg(feature = "fetch")]
3923        if cache_url {
3924            return match fetch_url(resource, encoding) {
3925                Ok((body, content_type)) => {
3926                    use std::hash::{Hash, Hasher};
3927                    let mut hasher = std::collections::hash_map::DefaultHasher::new();
3928                    body.hash(&mut hasher);
3929                    content_type.hash(&mut hasher);
3930                    format!("url:{:x}", hasher.finish())
3931                }
3932                Err(error) => format!("url-error:{error}"),
3933            };
3934        }
3935        #[cfg(not(feature = "fetch"))]
3936        let _ = (cache_url, encoding);
3937        return "url".to_string();
3938    }
3939    match std::fs::metadata(fs_path(resource)) {
3940        Ok(metadata) if metadata.is_file() => {
3941            use std::hash::Hasher;
3942            // Atomic saves and in-place writes may preserve both size and
3943            // mtime. Read regular files each poll using a fixed-size buffer,
3944            // so detection is portable and memory does not grow with input.
3945            let mut file = match std::fs::File::open(fs_path(resource)) {
3946                Ok(file) => file,
3947                Err(error) => return format!("file-error:{error}"),
3948            };
3949            let mut hasher = std::collections::hash_map::DefaultHasher::new();
3950            let mut buffer = [0u8; 64 * 1024];
3951            loop {
3952                match file.read(&mut buffer) {
3953                    Ok(0) => break,
3954                    Ok(count) => hasher.write(&buffer[..count]),
3955                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3956                    Err(error) => return format!("file-error:{error}"),
3957                }
3958            }
3959            format!("file:{:x}", hasher.finish())
3960        }
3961        Ok(_) => "not-file".to_string(),
3962        Err(_) => "missing".to_string(),
3963    }
3964}
3965
3966fn run_once(cli: Cli) -> ExitCode {
3967    run_once_with_fetch(cli, None)
3968}
3969
3970/// The console theme: the default theme, then `--theme-file`, then config
3971/// themes and `--theme-style` bindings, each overriding the one before.
3972fn cli_theme(cli: &Cli) -> rich::Theme {
3973    let mut theme = rich::Theme::default_theme();
3974    for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
3975        theme.insert(name.clone(), style.clone());
3976    }
3977    theme
3978}
3979
3980impl Cli {
3981    /// Whether any option changes the console theme.
3982    fn themed(&self) -> bool {
3983        !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
3984    }
3985}
3986
3987/// Read an upstream theme file (`Theme.read`: a `[styles]` section of
3988/// `name = style` lines). Errors name the file and, where the file has one,
3989/// the offending line, after `label` (`--theme-file PATH`, or the config
3990/// setting that named it).
3991///
3992/// Only a regular file of at most [`controls::THEME_FILE_LIMIT`] is read: a
3993/// FIFO would block every run and `/dev/zero` would never end.
3994fn read_theme_file(
3995    path: &str,
3996    label: &str,
3997) -> Result<std::collections::BTreeMap<String, Style>, String> {
3998    let limit = controls::THEME_FILE_LIMIT;
3999    // A path that is not valid Unicode is read by its own bytes.
4000    let path = &fs_path(path);
4001    // Checked before opening: opening a FIFO blocks until a writer appears.
4002    let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
4003    if !metadata.is_file() {
4004        return Err(format!("{label}: not a regular file"));
4005    }
4006    let too_large = || {
4007        format!(
4008            "{label}: larger than {}, the most read from a theme file",
4009            controls::size(limit)
4010        )
4011    };
4012    if metadata.len() > limit {
4013        return Err(too_large());
4014    }
4015    let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4016    let mut bytes = Vec::new();
4017    file.take(limit + 1)
4018        .read_to_end(&mut bytes)
4019        .map_err(|err| format!("{label}: {err}"))?;
4020    if bytes.len() as u64 > limit {
4021        return Err(too_large());
4022    }
4023    let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4024    let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4025        // Structural errors already say which line.
4026        rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4027        _ => match bad_style_line(&text) {
4028            Some(line) => format!("{label}: line {line}: {err}"),
4029            None => format!("{label}: {err}"),
4030        },
4031    })?;
4032    Ok(theme
4033        .names()
4034        .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4035        .collect())
4036}
4037
4038/// The 1-based line of the first `name = style` in `[styles]` whose style
4039/// does not parse, for pointing at a style error.
4040fn bad_style_line(text: &str) -> Option<usize> {
4041    let mut in_styles = false;
4042    for (index, line) in text.lines().enumerate() {
4043        let trimmed = line.trim();
4044        if trimmed.starts_with('[') {
4045            in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4046            continue;
4047        }
4048        if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4049            continue;
4050        }
4051        let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4052            continue;
4053        };
4054        if Style::parse(value.trim()).is_err() {
4055            return Some(index + 1);
4056        }
4057    }
4058    None
4059}
4060
4061fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4062    if cli.sanitize {
4063        cli.title = cli
4064            .title
4065            .take()
4066            .map(|title| sanitize_terminal_controls(&title));
4067        cli.caption = cli
4068            .caption
4069            .take()
4070            .map(|caption| sanitize_terminal_controls(&caption));
4071    }
4072
4073    if cli.batch {
4074        return run_batch(&cli);
4075    }
4076
4077    // With no flags and no resource, show the capability demo.
4078    if cli.mode == Mode::Auto && cli.resource.is_none() {
4079        run_demo(cli.no_color, std::time::Duration::ZERO);
4080        return success(&cli);
4081    }
4082
4083    let mut mode = match cli.mode {
4084        Mode::Auto => detect_mode(cli.resource.as_deref()),
4085        other => other,
4086    };
4087    if let Some(message) = transform_misplaced_in(&cli, mode) {
4088        return fail(&cli, ExitClass::Usage, message);
4089    }
4090
4091    // `view` decides what to show from the resource itself: an existing
4092    // renderer, a numbered source view, or a hex dump of binary input. The
4093    // resource is read once, here, so stdin is not read twice.
4094    let mut prefetched = prefetched;
4095    let mut view_as = None;
4096    let mut view_bytes = Vec::new();
4097    if mode == Mode::View {
4098        let ext = cli
4099            .resource
4100            .as_deref()
4101            .filter(|r| *r != "-")
4102            .and_then(resource_ext);
4103        // Images and logs are read by their own renderers; anything else is
4104        // read here, up to a limit, and sniffed.
4105        let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4106        if !matches!(
4107            by_name,
4108            viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4109        ) {
4110            // A notebook is one JSON document, often large with its outputs:
4111            // cutting it short would only make it invalid.
4112            let limit = if by_name == viewers::ViewAs::Notebook {
4113                controls::INSPECT_LIMIT
4114            } else {
4115                controls::VIEW_LIMIT
4116            };
4117            let more;
4118            (view_bytes, more) = match read_window(&cli, 0, limit) {
4119                Ok(read) => read,
4120                Err(err) => return fail(&cli, ExitClass::Input, err),
4121            };
4122            let name = cli.resource.as_deref().unwrap_or("<stdin>");
4123            let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4124            if kind == viewers::ViewAs::Binary {
4125                if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4126                    view_bytes.truncate(controls::BYTES_LIMIT as usize);
4127                    viewers::notice(
4128                        name,
4129                        &controls::size(controls::BYTES_LIMIT),
4130                        "use rich hex with --offset and --length for the rest",
4131                    );
4132                }
4133            } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4134                .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4135            {
4136                // Numbered source and patches render line by line.
4137                view_bytes.truncate(end);
4138                viewers::notice(
4139                    name,
4140                    &format!("{} lines", controls::LINE_LIMIT),
4141                    "use a pager or rich --syntax for the whole file",
4142                );
4143            } else if more {
4144                viewers::notice(
4145                    name,
4146                    &controls::size(limit),
4147                    "use a pager or rich --syntax for the whole file",
4148                );
4149            }
4150        }
4151        let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4152        mode = match kind {
4153            viewers::ViewAs::Markdown => Mode::Markdown,
4154            viewers::ViewAs::Csv => Mode::Csv,
4155            viewers::ViewAs::Notebook => Mode::Ipynb,
4156            viewers::ViewAs::Image => Mode::Image,
4157            viewers::ViewAs::Gif => Mode::Gif,
4158            viewers::ViewAs::Log => Mode::Log,
4159            _ => Mode::View,
4160        };
4161        let text = String::from_utf8_lossy(&view_bytes).into_owned();
4162        prefetched = Some((text, None));
4163        view_as = Some(kind);
4164    }
4165
4166    // Modes that render incrementally write directly to the console instead of
4167    // composing one renderable, so no `ForceWidth` wrapper can reach them.
4168    // Upstream's `Console(emoji=emoji, force_terminal=…)`: emoji codes stay
4169    // as typed unless `--emoji` is given.
4170    let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
4171    if cli.themed() {
4172        builder = builder.theme(cli_theme(&cli));
4173    }
4174    // Parallel workers spool output, but render for the parent's destination.
4175    // Pager policy still checks the real stdout handle before starting a pager.
4176    if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4177        builder = builder.force_terminal(terminal == "1");
4178    }
4179    if cli.force_terminal {
4180        builder = builder.force_terminal(true);
4181    }
4182    if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4183        builder = builder.width(width);
4184    }
4185    // A watched resource renders into a live region, one column narrower than
4186    // the terminal (the Live display keeps the last column free).
4187    if let Some(width) = watch::capture_width() {
4188        builder = builder.width(width);
4189    }
4190    let mut console = builder.build();
4191    render_target::attach(&mut console);
4192    console.install_extensions();
4193    plugins::install_highlighters(&mut console);
4194    apply_code_highlighting(&mut console, &cli);
4195
4196    if mode == Mode::Rule {
4197        if let Some(title) = &cli.resource {
4198            if let Err(err) = Text::from_markup(title) {
4199                return fail(&cli, ExitClass::Data, err.to_string());
4200            }
4201        }
4202    }
4203
4204    // SVG export needs a title (the resource's basename, else "rich") and a
4205    // stable id. Upstream's auto id hashes Python reprs, so we use a fixed one
4206    // (see the rich crate's svg module / DIVERGENCES #15). A literal resource
4207    // (print-mode markup, a rule's title) is not a path, so splitting it on
4208    // `/` or `\` would title `[b]Hi[/] there` as `] there`: it gets "rich".
4209    let literal = mode == Mode::Rule
4210        || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4211    let mut svg_title = cli
4212        .resource
4213        .as_deref()
4214        .filter(|r| *r != "-" && !literal)
4215        .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4216        .unwrap_or_else(|| "rich".to_string());
4217    if cli.sanitize {
4218        svg_title = sanitize_terminal_controls(&svg_title);
4219    }
4220    #[allow(unused_mut)]
4221    let mut export = Export {
4222        html_path: cli.export_html.as_deref(),
4223        svg_path: cli.export_svg.as_deref(),
4224        svg_title: &svg_title,
4225        pager: cli.pager,
4226        auto_pager: cli.auto_pager,
4227    };
4228
4229    // GIF playback consumes the resource list itself (it can take several) and
4230    // animates rather than rendering once.
4231    if mode == Mode::Gif {
4232        return play_gifs(&cli, &console);
4233    }
4234
4235    // `--diff` consumes both resources and reports rather than rendering one:
4236    // perceptually for two images, as a text, source or patch diff otherwise.
4237    if mode == Mode::Diff {
4238        let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4239        if !images {
4240            return run_text_diff(&cli, &console, &export);
4241        }
4242        if let Some(flag) = cli.tools.diff_option() {
4243            return fail(
4244                &cli,
4245                ExitClass::Usage,
4246                format!("{flag} applies to text diffs, not images"),
4247            );
4248        }
4249        #[cfg(feature = "art")]
4250        return run_diff(&cli, &console, &export);
4251        #[cfg(not(feature = "art"))]
4252        return fail(
4253            &cli,
4254            ExitClass::Usage,
4255            "this build has no image support (rebuild with the `art` feature)",
4256        );
4257    }
4258
4259    // `--image` renders a single still image directly through rich-art's
4260    // `ImageArt` facade rather than composing a decorated renderable.
4261    #[cfg(feature = "art")]
4262    if mode == Mode::Image {
4263        return run_image(&cli, &console, &export);
4264    }
4265    #[cfg(not(feature = "art"))]
4266    if mode == Mode::Image {
4267        return fail(
4268            &cli,
4269            ExitClass::Usage,
4270            "this build has no image support (rebuild with the `art` feature)",
4271        );
4272    }
4273
4274    if mode.streams_records() {
4275        return run_json_lines(&cli, &console, mode == Mode::Log);
4276    }
4277
4278    // A rule takes its optional title from the resource string directly (no
4279    // fetch/read) — but it still goes through the decorators, because upstream
4280    // wraps it like anything else, so `--rule --panel` really does draw a panel.
4281    if mode == Mode::Rule {
4282        let mut rule = match cli.resource.as_deref() {
4283            Some(title) if title != "-" && cli.sanitize => {
4284                Rule::new(sanitize_terminal_controls(title))
4285            }
4286            Some(title) if title != "-" => Rule::new(title),
4287            _ => Rule::line(),
4288        };
4289        // `Rule(resource, style=rule_style, characters=rule_char or "─",
4290        // align="center" if justify in ("full", "default") else justify)`.
4291        // Upstream's default is an explicit `bright_green`, not the theme's
4292        // `rule.line`, so a theme cannot restyle a plain `--rule`.
4293        let style = cli
4294            .rule_style
4295            .clone()
4296            .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4297        rule = rule.style(style);
4298        if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4299            // The line repeats its characters across the terminal, so a
4300            // control in them acts once per cell unless shown.
4301            if cli.sanitize {
4302                rule = rule.characters(sanitize_terminal_controls(characters));
4303            } else {
4304                rule = rule.characters(characters);
4305            }
4306        }
4307        match cli.text_justify {
4308            Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4309            Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4310            _ => {}
4311        }
4312        // `Rule.__rich_measure__` is `Measurement(1, 1)`: a rule claims no width
4313        // of its own, so a fitted panel around one is 5 cells wide.
4314        return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4315    }
4316
4317    // The viewer commands that need no text content.
4318    if mode == Mode::Capture {
4319        let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4320            Ok(redactor) => redactor,
4321            Err(err) => return fail(&cli, ExitClass::Usage, err),
4322        };
4323        // The command draws inside the panel: its width, less the two
4324        // borders and their padding.
4325        let width = cli
4326            .width
4327            .unwrap_or_else(|| console.width())
4328            .saturating_sub(4)
4329            .max(1);
4330        // A recording that cannot be written is found out before the
4331        // command runs, not after.
4332        let cast = match viewers::cast_path(&cli.viewers) {
4333            Some(path) => match std::fs::File::create(path) {
4334                Ok(file) => Some((path, file)),
4335                Err(err) => {
4336                    return fail(
4337                        &cli,
4338                        ExitClass::Input,
4339                        format!("cannot write {path}: {err}"),
4340                    )
4341                }
4342            },
4343            None => None,
4344        };
4345        let mut captured = match viewers::capture(&cli.resources, width) {
4346            Ok(captured) => captured,
4347            Err(err) => return fail(&cli, ExitClass::Input, err),
4348        };
4349        if let Some(notice) = &captured.notice {
4350            eprintln!("rich: capture: {notice}");
4351        }
4352        // Before the panel, the exports and the cast see any of it.
4353        if let Some(redactor) = &redactor {
4354            captured.redact(redactor);
4355        }
4356        if cli.sanitize {
4357            captured.sanitize();
4358        }
4359        if let Some((path, mut file)) = cast {
4360            let cast = viewers::asciicast(&captured, width, console.height());
4361            if let Err(err) = file.write_all(cast.as_bytes()) {
4362                return fail(
4363                    &cli,
4364                    ExitClass::Input,
4365                    format!("cannot write {path}: {err}"),
4366                );
4367            }
4368        }
4369        let view = viewers::capture_view(&captured);
4370        let fit = view.measure(&console, &console.options()).maximum;
4371        // Like `time` or `env`, exit with the command's status once its
4372        // output is shown and exported, so scripts and CI see a failure.
4373        let code = captured.exit_code();
4374        return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4375            if code == 0 || shown.is_err() {
4376                return emit_exit_code(cli, shown);
4377            }
4378            // A failed command's report replaces the success envelope.
4379            if cli.report_format == ReportFormat::Json {
4380                eprintln!("{}", viewers::capture_report(&captured));
4381            }
4382            ExitCode::from(code)
4383        });
4384    }
4385    if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4386        return fail(
4387            &cli,
4388            ExitClass::Usage,
4389            "this build has no Mermaid support (the mermaid feature)",
4390        );
4391    }
4392    if mode == Mode::Env {
4393        let view = viewers::env(&cli.viewers, &cli.resources);
4394        let fit = view.measure(&console, &console.options()).maximum;
4395        return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4396    }
4397    if mode == Mode::Profile {
4398        let profile = match profile::profile(&cli) {
4399            Ok(profile) => profile,
4400            Err((class, err)) => return fail(&cli, class, err),
4401        };
4402        // As `doctor --report json`: the profile is the stdout document.
4403        if cli.report_format == ReportFormat::Json {
4404            let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4405            authoring::out(&format!("{json}\n"));
4406            return ExitClass::Success.exit_code();
4407        }
4408        let view: Box<dyn Renderable> = Box::new(profile);
4409        let fit = view.measure(&console, &console.options()).maximum;
4410        return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4411    }
4412    if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4413        let view = match mode {
4414            Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4415            Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4416            _ => chart::chart(&cli).map(|view| (view, None)),
4417        };
4418        let (view, gate) = match view {
4419            Ok(view) => view,
4420            Err((class, err)) => return fail(&cli, class, err),
4421        };
4422        let fit = view.measure(&console, &console.options()).maximum;
4423        // A failed gate (`rich deps --audit` with a vulnerability) exits 5
4424        // once the report is shown, with one report envelope.
4425        return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4426            match gate {
4427                Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4428                _ => emit_exit_code(cli, shown),
4429            }
4430        });
4431    }
4432    if matches!(mode, Mode::Hex | Mode::Unicode) {
4433        // Only the bytes shown are read: an explicit `--length`, else up to
4434        // the limit, with a notice when more follow.
4435        let hex = mode == Mode::Hex;
4436        let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4437        let length = cli.viewers.length().filter(|_| hex);
4438        let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4439        let (bytes, more) = match read_window(&cli, offset, max) {
4440            Ok(read) => read,
4441            Err(err) => return fail(&cli, ExitClass::Input, err),
4442        };
4443        if more && length.is_none() {
4444            let hint = if hex {
4445                "use --offset and --length for the rest"
4446            } else {
4447                "pass a shorter input"
4448            };
4449            viewers::notice(
4450                cli.resource.as_deref().unwrap_or("<stdin>"),
4451                &controls::size(controls::BYTES_LIMIT),
4452                hint,
4453            );
4454        }
4455        let view = if hex {
4456            viewers::hex(&cli.viewers, bytes, offset)
4457        } else {
4458            viewers::unicode(&cli.viewers, &bytes)
4459        };
4460        let view = match view {
4461            Ok(view) => view,
4462            Err(err) => return fail(&cli, ExitClass::Usage, err),
4463        };
4464        let fit = view.measure(&console, &console.options()).maximum;
4465        return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4466    }
4467
4468    // Obtain the content: fetch it over HTTP(S) when the resource is a URL,
4469    // treat it as a literal markup string under `--print`, else read the
4470    // file/stdin. A URL also yields a `Content-Type` used below.
4471    let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4472    let (mut content, content_type) = if let Some(fetched) = prefetched {
4473        fetched
4474    } else if resource_is_url {
4475        match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4476            Ok(fetched) => fetched,
4477            Err(err) => {
4478                return fail(&cli, ExitClass::Input, err);
4479            }
4480        }
4481    } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4482        (cli.resource.clone().unwrap(), None)
4483    } else {
4484        // `inspect` is ours: it parses at most a documented limit.
4485        let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4486        match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4487            Ok(content) => (content, None),
4488            Err(err) => {
4489                let name = cli.resource.as_deref().unwrap_or("<stdin>");
4490                return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4491            }
4492        }
4493    };
4494    // `ansi explain` shows escapes visibly; neutralising them first would
4495    // leave nothing to explain.
4496    if cli.sanitize && mode != Mode::AnsiExplain {
4497        content = sanitize_terminal_controls(&content);
4498    }
4499
4500    // `--format` routes input automatic mode would print as text (stdin, no
4501    // extension) — or, with a named format, any input — to a renderer.
4502    let mut routed_language = None;
4503    let mut routed = false;
4504    if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4505        if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4506            routed = true;
4507            mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4508                inspect::Route::Json => Mode::Json,
4509                inspect::Route::Syntax(lexer) => {
4510                    routed_language = Some(lexer);
4511                    Mode::Syntax
4512                }
4513                inspect::Route::Text => Mode::Auto,
4514            };
4515        }
4516    }
4517
4518    // For a URL that neither a flag nor a telltale extension resolved: upstream
4519    // renders fetched content as syntax-highlighted source by default, unless the
4520    // Content-Type marks it markdown / json / csv.
4521    if resource_is_url && mode == Mode::Auto && !routed {
4522        mode = content_type
4523            .as_deref()
4524            .and_then(content_type_mode)
4525            .unwrap_or(Mode::Syntax);
4526    }
4527
4528    // Upstream renders anything it does not recognise as syntax. This port
4529    // prints plain text instead, until a source option asks for more.
4530    if mode == Mode::Auto && cli.source.wants_syntax() {
4531        mode = Mode::Syntax;
4532    }
4533
4534    // Pre-parse JSON so a parse error surfaces before any (HTML) rendering.
4535    let json_content = if mode == Mode::Json && cli.sanitize {
4536        match sanitize_json_source(content.trim()) {
4537            Ok(content) => Some(content),
4538            Err(err) => {
4539                return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4540            }
4541        }
4542    } else {
4543        None
4544    };
4545
4546    let json = if mode == Mode::Json {
4547        let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4548        match Json::new(source) {
4549            Ok(json) => Some(json),
4550            Err(err) => {
4551                return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4552            }
4553        }
4554    } else {
4555        None
4556    };
4557
4558    // Same treatment for a notebook. Without this the parse failure was printed
4559    // from inside the render closure, which cannot influence the exit code — so
4560    // a broken notebook reported an error and exited 0, while a broken .json
4561    // exited 1. A malformed input must not look like success to a script.
4562    if mode == Mode::Ipynb {
4563        let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4564            Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4565                if cli.sanitize {
4566                    sanitize_json_value(&mut value);
4567                }
4568                value
4569            }
4570            Ok(_) => {
4571                return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4572            }
4573            Err(err) => {
4574                return fail(
4575                    &cli,
4576                    ExitClass::Data,
4577                    format!("invalid notebook JSON: {err}"),
4578                );
4579            }
4580        };
4581        let renderable = match build_ipynb(&notebook, cli.hyperlinks, cli.justify, &cli.source) {
4582            Ok(renderable) => renderable,
4583            Err(err) => return fail(&cli, ExitClass::Usage, err),
4584        };
4585        let fit = renderable.measure(&console, &console.options()).maximum;
4586        return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4587    }
4588
4589    // Markup comes from the user in Print mode, so a mistake in it should be
4590    // reported rather than printed literally (upstream raises `MarkupError`).
4591    // Checked up front because the render closure below cannot fail.
4592    if mode == Mode::Print {
4593        if let Err(err) = console.try_build_text(&content) {
4594            return fail(&cli, ExitClass::Data, err.to_string());
4595        }
4596    }
4597
4598    // The highlighting language comes from the resource extension, falling back
4599    // (for a URL) to a lexer guessed from its Content-Type. An *uninformative*
4600    // extension (.txt/.log/…) doesn't shadow a Content-Type that names a real
4601    // language, so `snippet.txt` served as `text/x-python` still highlights.
4602    let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4603        .filter(|ext| !is_uninformative_ext(ext));
4604    let language = routed_language
4605        .map(str::to_string)
4606        .or(extension)
4607        .or_else(|| {
4608            content_type
4609                .as_deref()
4610                .and_then(content_type_lexer)
4611                .map(str::to_string)
4612        })
4613        .unwrap_or_default();
4614
4615    // `--format` or a URL's Content-Type can still route away from text.
4616    if let Some(message) = transform_misplaced_in(&cli, mode) {
4617        return fail(&cli, ExitClass::Usage, message);
4618    }
4619
4620    // Build the renderable, and the width a non-expanding `Panel`/`Padding`
4621    // would shrink around it — upstream's `Measurement.get(…).maximum`.
4622    let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4623        // `Markdown` defines no `__rich_measure__`, so upstream measures it as
4624        // the whole available width and a panel around it never shrinks.
4625        Mode::Markdown => {
4626            // `:micro:name:` outside code draws as its asset.
4627            #[cfg(feature = "art")]
4628            let micro = micro::markdown(&console, &content, cli.micro_project);
4629            #[cfg(feature = "art")]
4630            let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
4631            #[cfg(not(feature = "art"))]
4632            let source = content.as_str();
4633            let mut markdown = build_markdown(source, cli.hyperlinks);
4634            // ```mermaid and ```dot fences draw as diagrams unless
4635            // `--mermaid-backend off` / `--dot-backend off`.
4636            if let Some(fences) = plugin_registry_with(
4637                cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4638                cli.dot_backend != Some(sources::DotBackend::Off),
4639            )
4640            .fences()
4641            {
4642                markdown = markdown.fence_renderer(fences);
4643            }
4644            #[cfg(feature = "art")]
4645            let view: Box<dyn Renderable> = match micro {
4646                Some(micro) => Box::new(micro.view(markdown)),
4647                None => Box::new(markdown),
4648            };
4649            #[cfg(not(feature = "art"))]
4650            let view: Box<dyn Renderable> = Box::new(markdown);
4651            (view, None)
4652        }
4653        // Upstream is `RestructuredText(data, code_theme=theme,
4654        // default_lexer=lexer or "python", show_errors=False)`; the theme
4655        // reaches its code blocks through the console's code highlighting.
4656        Mode::Rst => {
4657            let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4658            let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4659            (Box::new(document), None)
4660        }
4661        #[cfg(feature = "mermaid")]
4662        Mode::Mermaid => {
4663            // The command itself asks for a diagram, so it may use mmdc.
4664            let default = if cfg!(feature = "mmdc") {
4665                MermaidBackend::Mmdc
4666            } else {
4667                MermaidBackend::Text
4668            };
4669            let backend = match cli.mermaid_backend.unwrap_or(default) {
4670                MermaidBackend::Off => MermaidBackend::Text,
4671                chosen => chosen,
4672            };
4673            let diagram =
4674                rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4675            (Box::new(diagram), None)
4676        }
4677        Mode::Dot => {
4678            // `--dot-backend graphviz` gives `--export-svg` Graphviz's own
4679            // SVG; the terminal still shows the native drawing.
4680            let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
4681            let mut svg_done = false;
4682            if let (true, Some(path)) = (graphviz, export.svg_path) {
4683                svg_done = sources::graphviz_svg(&content, path);
4684                if svg_done {
4685                    export.svg_path = None;
4686                }
4687            }
4688            match sources::dot(&content, cli.resource.as_deref()) {
4689                Ok(diagram) => (diagram, None),
4690                // Graphviz drew what the native parser refuses: say so, and
4691                // keep its SVG.
4692                Err((_, err)) if svg_done => {
4693                    eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
4694                    return success(&cli);
4695                }
4696                Err((class, err)) => return fail(&cli, class, err),
4697            }
4698        }
4699        Mode::Json => {
4700            // `rich.json.JSON` sets `text.no_wrap = True`, and every decorator
4701            // upstream can wrap the document in — `Padding`, `Panel`, `Styled`,
4702            // `ForceWidth` — is a `ConsoleRenderable`, so the `Text` reaches
4703            // `Text.__rich_console__` with the flag intact and each line is
4704            // CROPPED at the width. A *bare* document is a `Text`, which
4705            // `_collect_renderables` re-joins through `Text(sep).join(...)`;
4706            // `Text.join` copies its metadata from the separator, so the flag
4707            // is lost and the default fold wrap applies instead.
4708            //
4709            // `--left/--center/--right` is not on the list: `align_append`
4710            // wraps whatever `check_text` produced, so the join — and the loss
4711            // — has already happened by the time `Align` sees it.
4712            //
4713            // `--pager` skips `ForceWidth`, but pages through
4714            // `console.render_lines(renderable, …)`, which renders the `Text`
4715            // directly and keeps the flag.
4716            let nested = cli.padding.is_some()
4717                || cli.panel.is_some()
4718                || cli.style.is_some()
4719                || cli.width.is_some()
4720                || cli.pager
4721                || cli.auto_pager;
4722            let json = json.expect("json parsed above").no_wrap(nested);
4723            #[cfg(feature = "json-escape-safe")]
4724            let json = json.escape_safe(true);
4725            let fit = measure_rendered(&console, &json);
4726            (Box::new(json), Some(fit))
4727        }
4728        Mode::Csv => {
4729            if let Err(err) = validate_labels(&cli) {
4730                return fail(&cli, ExitClass::Data, err);
4731            }
4732            // `.tsv` only picks the fallback dialect; the sniffer reads the file
4733            // itself, so a comma-separated `.tsv` still renders as a table.
4734            let fallback = csv_fallback_delimiter(cli.resource.as_deref());
4735            let table = match build_csv_table(
4736                &content,
4737                fallback,
4738                cli.title.as_deref(),
4739                cli.caption.as_deref(),
4740                (cli.source.head, cli.source.tail),
4741                cli.source.infer,
4742            ) {
4743                Some(table) => table,
4744                // Upstream's `on_error(str(error))`. The message is CPython's,
4745                // verbatim, because it is the one a script would grep for.
4746                None => {
4747                    return fail(&cli, ExitClass::Data, "Could not determine delimiter");
4748                }
4749            };
4750            // `:micro:name:` in the title and caption, where `:emoji:` codes
4751            // expand (with `--emoji`).
4752            #[cfg(feature = "art")]
4753            let table = {
4754                let mut table = table;
4755                let label = |label: &Option<String>, style| {
4756                    label.as_deref().and_then(|label| {
4757                        micro::table_label(&console, label, style, cli.micro_project)
4758                    })
4759                };
4760                if let Some(title) = label(&cli.title, "table.title") {
4761                    table = table.title_text(title);
4762                }
4763                if let Some(caption) = label(&cli.caption, "table.caption") {
4764                    table = table.caption_text(caption);
4765                }
4766                table
4767            };
4768            // Stream undecorated CSV rows: measuring or collecting the complete
4769            // styled table first multiplies memory by hundreds on tall inputs.
4770            // Containers, alignment, paging and exports still need the full view.
4771            if cli.panel.is_none()
4772                && cli.padding.is_none()
4773                && cli.style.is_none()
4774                && cli.justify.is_none()
4775                && cli.max_width.is_none()
4776                && !cli.soft
4777                && !cli.pager
4778                && !cli.auto_pager
4779                && cli.export_html.is_none()
4780                && cli.export_svg.is_none()
4781                && !watch::capturing()
4782                && !micro_pending()
4783            {
4784                let mut options = console.options();
4785                options.max_width = cli.width.unwrap_or_else(|| {
4786                    table
4787                        .measure(&console, &options)
4788                        .maximum
4789                        .min(options.max_width)
4790                        .max(1)
4791                });
4792                let stdout = std::io::stdout();
4793                let mut output = std::io::BufWriter::new(stdout.lock());
4794                let result = table
4795                    .try_for_each_line(&console, &options, |line| {
4796                        let line = Segment::crop_lines(&line, console.width());
4797                        writeln!(output, "{}", console.segments_to_string(&line))
4798                    })
4799                    .and_then(|()| output.flush());
4800                if let Err(error) = result {
4801                    // A consumer such as `head` may finish before this table.
4802                    // Match the other stdout paths: a closed pipe is success.
4803                    if error.kind() == std::io::ErrorKind::BrokenPipe {
4804                        return success(&cli);
4805                    }
4806                    return fail(
4807                        &cli,
4808                        ExitClass::Input,
4809                        format!("could not write CSV output: {error}"),
4810                    );
4811                }
4812                return success(&cli);
4813            }
4814            let fit = measure_rendered(&console, &table);
4815            (Box::new(table), Some(fit))
4816        }
4817        // `--filter`, `--transform` and `--highlight` work on the highlighted
4818        // text.
4819        Mode::Syntax if cli.data.transform_option().is_some() => {
4820            let language = cli.source.lexer.as_deref().unwrap_or(&language);
4821            // `--head`/`--tail` pick the lines first, as `Syntax`'s
4822            // `line_range` does on the other path.
4823            let code = match cli.source.line_range(&content) {
4824                Ok(Some((start, end))) => {
4825                    let start = start.max(1) as usize - 1;
4826                    let end = end.max(0) as usize;
4827                    let lines: Vec<&str> = content.lines().collect();
4828                    lines
4829                        .get(start.min(lines.len())..end.min(lines.len()))
4830                        .unwrap_or_default()
4831                        .join("\n")
4832                }
4833                Ok(None) => content.clone(),
4834                Err(err) => return fail(&cli, ExitClass::Usage, err),
4835            };
4836            let text = Syntax::new(code.as_str(), language).highlight_for(&console);
4837            let text = match cli
4838                .data
4839                .text_pipeline(&plugin_registry(MermaidBackend::Text))
4840                .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4841            {
4842                Ok(text) => text,
4843                Err(err) => return fail(&cli, ExitClass::Data, err),
4844            };
4845            let fit = text.measurement().1;
4846            // Keep `Syntax`'s layout: every row filled to the width in the
4847            // code background, so a transform changes only what it matches.
4848            let background = match text.base_style() {
4849                rich::style::StyleType::Style(style) => style.clone(),
4850                rich::style::StyleType::Name(_) => rich::Style::new(),
4851            };
4852            // The text's own final empty line (after a trailing newline) is
4853            // drawn by `Padding`, as `Syntax` draws it.
4854            let padded =
4855                rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
4856            (Box::new(padded), Some(fit))
4857        }
4858        Mode::Syntax => {
4859            let syntax = match cli.source.syntax(&content, &language) {
4860                Ok(syntax) => syntax,
4861                Err(err) => return fail(&cli, ExitClass::Usage, err),
4862            };
4863            // `Syntax.__rich_measure__`: the widest source line, plus the
4864            // line-number column when there is one.
4865            let fit = syntax.measure(&console, &console.options()).maximum;
4866            (Box::new(syntax), Some(fit))
4867        }
4868        Mode::View => {
4869            let view: Box<dyn Renderable> = match view_as {
4870                Some(viewers::ViewAs::Patch) => {
4871                    let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
4872                    match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
4873                        Ok(outcome) => outcome.renderable,
4874                        Err(err) => return fail(&cli, ExitClass::Data, err),
4875                    }
4876                }
4877                Some(viewers::ViewAs::Binary) => {
4878                    match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
4879                        Ok(view) => view,
4880                        Err(err) => return fail(&cli, ExitClass::Usage, err),
4881                    }
4882                }
4883                Some(viewers::ViewAs::Source(ref lexer)) => {
4884                    if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
4885                        eprintln!("{summary}");
4886                    }
4887                    viewers::source(&cli.viewers, &content, lexer)
4888                }
4889                _ => viewers::source(&cli.viewers, &content, "txt"),
4890            };
4891            let fit = view.measure(&console, &console.options()).maximum;
4892            (view, Some(fit))
4893        }
4894        Mode::AnsiExplain => {
4895            let view = tools::ansi_explain(&cli.tools, &content);
4896            let fit = view.measure(&console, &console.options()).maximum;
4897            (view, Some(fit))
4898        }
4899        Mode::Inspect => {
4900            let encoding = cli.extensions.encoding;
4901            let read = |path: &str| {
4902                read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
4903                    .map_err(|err| format!("cannot read {path}: {err}"))
4904            };
4905            let view = match inspect::build(&cli.data, &content, cli.resource.as_deref(), read) {
4906                Ok(view) => view,
4907                Err(err) => return fail(&cli, ExitClass::Data, err),
4908            };
4909            let fit = view.measure(&console, &console.options()).maximum;
4910            (view, Some(fit))
4911        }
4912        // Print + auto: parse markup (Print) or take plain text (auto), then
4913        // `--filter` and `--highlight`.
4914        _ => {
4915            // `:micro:name:` goes where `:emoji:` codes go: in --print
4916            // markup with --emoji.
4917            #[cfg(feature = "art")]
4918            let micro = (mode == Mode::Print && cli.emoji)
4919                .then(|| micro::print_markup(&console, &content, cli.micro_project))
4920                .flatten();
4921            #[cfg(feature = "art")]
4922            let mut text = match micro {
4923                Some(text) => text,
4924                None if mode == Mode::Print => console.build_text(&content),
4925                None => Text::new(content.as_str()),
4926            };
4927            #[cfg(not(feature = "art"))]
4928            let mut text = if mode == Mode::Print {
4929                console.build_text(&content)
4930            } else {
4931                Text::new(content.as_str())
4932            };
4933            // `Text.from_markup(…, justify=justify)` and `renderable.no_wrap =
4934            // no_wrap`. A bare text loses both to `print`'s `Text.join`, as
4935            // upstream's does; `--width`, `--panel` and the rest keep them.
4936            if let Some(justify) = cli.text_justify {
4937                text.set_justify(justify);
4938            }
4939            if cli.source.no_wrap {
4940                text.set_no_wrap(Some(true));
4941            }
4942            let text = match cli
4943                .data
4944                .text_pipeline(&plugin_registry(MermaidBackend::Text))
4945                .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4946            {
4947                Ok(text) => text,
4948                Err(err) => return fail(&cli, ExitClass::Data, err),
4949            };
4950            let fit = text.measurement().1;
4951            (Box::new(text), Some(fit))
4952        }
4953    };
4954    decorate_and_emit(&cli, &console, &export, renderable, fit)
4955}
4956
4957/// Turn an "everything written successfully" flag into a process exit code.
4958/// A failed `--export-html`/`--export-svg` write must not report success.
4959#[allow(dead_code)]
4960fn exit_code(ok: bool) -> ExitCode {
4961    if ok {
4962        ExitCode::SUCCESS
4963    } else {
4964        ExitCode::FAILURE
4965    }
4966}
4967
4968fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
4969    match result {
4970        Ok(()) => success(cli),
4971        Err(message) => fail(cli, ExitClass::Input, message),
4972    }
4973}
4974
4975fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
4976    let output = console.render_export(renderable);
4977    let stdout = std::io::stdout();
4978    let mut lock = stdout.lock();
4979    lock.write_all(output.as_bytes())
4980}
4981
4982fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
4983    if cli.extensions.encoding.is_some() {
4984        return fail(
4985            cli,
4986            ExitClass::Usage,
4987            "--encoding is not supported with streaming JSONL/log mode",
4988        );
4989    }
4990    if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
4991        return fail(
4992            cli,
4993            ExitClass::Usage,
4994            "streaming JSONL/log mode reads files or stdin, not URLs",
4995        );
4996    }
4997
4998    let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
4999        Some(path) if path != "-" => {
5000            let file = &fs_path(path);
5001            if file.is_dir() {
5002                return fail(
5003                    cli,
5004                    ExitClass::Input,
5005                    format!("cannot read {path}: is a directory, not a file"),
5006                );
5007            }
5008            match std::fs::File::open(file) {
5009                Ok(file) => Box::new(std::io::BufReader::new(file)),
5010                Err(err) => {
5011                    return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5012                }
5013            }
5014        }
5015        _ => {
5016            if std::io::stdin().is_terminal() {
5017                let eof = if cfg!(windows) {
5018                    "Ctrl-Z then Enter"
5019                } else {
5020                    "Ctrl-D"
5021                };
5022                eprintln!("rich: reading stdin; finish input with {eof}");
5023            }
5024            Box::new(std::io::BufReader::new(std::io::stdin()))
5025        }
5026    };
5027
5028    let mut line = String::new();
5029    let mut line_number = 0usize;
5030    loop {
5031        line.clear();
5032        let read = match input.read_line(&mut line) {
5033            Ok(read) => read,
5034            Err(err) => {
5035                return fail(
5036                    cli,
5037                    ExitClass::Input,
5038                    format!("cannot read JSONL stream: {err}"),
5039                );
5040            }
5041        };
5042        if read == 0 {
5043            break;
5044        }
5045        line_number += 1;
5046        let record = line.trim_end_matches(['\r', '\n']);
5047        let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5048            Ok(value) => value,
5049            Err(err) => {
5050                return fail(
5051                    cli,
5052                    ExitClass::Data,
5053                    format!("invalid JSONL at line {line_number}: {err}"),
5054                );
5055            }
5056        };
5057        if cli.sanitize {
5058            sanitize_json_value(&mut value);
5059        }
5060        let wrote = if log_mode {
5061            if cli.log_presentation == "rich" {
5062                write_rendered_stdout(console, &structured_log::event(&value))
5063            } else {
5064                let text = Text::new(format_log_record(&value));
5065                write_rendered_stdout(console, &text)
5066            }
5067        } else {
5068            let source = serde_json::to_string(&value).expect("JSON value serializes");
5069            let json = Json::new(&source).expect("serialized JSON parses");
5070            write_rendered_stdout(console, &json)
5071        };
5072        if let Err(error) = wrote {
5073            if error.kind() == std::io::ErrorKind::BrokenPipe {
5074                return success(cli);
5075            }
5076            return fail(
5077                cli,
5078                ExitClass::Input,
5079                format!("could not write JSONL output: {error}"),
5080            );
5081        }
5082    }
5083    success(cli)
5084}
5085
5086fn format_log_record(value: &serde_json::Value) -> String {
5087    let Some(object) = value.as_object() else {
5088        return serde_json::to_string(value).expect("JSON value serializes");
5089    };
5090    let take_value = |keys: &[&'static str]| {
5091        keys.iter().find_map(|key| {
5092            object.get(*key).map(|value| {
5093                let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
5094                    serde_json::to_string(value).expect("JSON value serializes")
5095                });
5096                (*key, display, value.is_string())
5097            })
5098        })
5099    };
5100    let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
5101    let level = take_value(&["level", "severity"]);
5102    let message = take_value(&["message", "msg"]);
5103    let mut rest = serde_json::Map::new();
5104    for (key, value) in object {
5105        let consumed = timestamp
5106            .as_ref()
5107            .is_some_and(|(consumed, _, _)| consumed == key)
5108            || level
5109                .as_ref()
5110                .is_some_and(|(consumed, _, _)| consumed == key)
5111            || message
5112                .as_ref()
5113                .is_some_and(|(consumed, _, _)| consumed == key);
5114        if !consumed {
5115            rest.insert(key.clone(), value.clone());
5116        }
5117    }
5118    let mut parts = Vec::new();
5119    if let Some((_, timestamp, _)) = timestamp {
5120        parts.push(timestamp);
5121    }
5122    if let Some((_, level, is_string)) = level {
5123        parts.push(if is_string {
5124            level.to_ascii_uppercase()
5125        } else {
5126            level
5127        });
5128    }
5129    if let Some((_, message, _)) = message {
5130        parts.push(message);
5131    }
5132    if !rest.is_empty() {
5133        parts.push(
5134            serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
5135        );
5136    }
5137    if parts.is_empty() {
5138        serde_json::to_string(value).expect("JSON value serializes")
5139    } else {
5140        parts.join(" ")
5141    }
5142}
5143
5144/// Whether `value` matches rich-cli's `is_number`, i.e. Python's
5145/// `re.fullmatch(r"\-?[0-9]*?\.?[0-9]*?", value)`.
5146///
5147/// Note how loose that is: `-`, `.`, `-.` and the empty string all pass, and a
5148/// leading space does not. The caller only asks about non-empty cells.
5149fn is_number(value: &str) -> bool {
5150    let body = value.strip_prefix('-').unwrap_or(value);
5151    let (integer, fraction) = match body.split_once('.') {
5152        Some((integer, fraction)) => (integer, Some(fraction)),
5153        None => (body, None),
5154    };
5155    let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5156    digits(integer) && fraction.is_none_or(digits)
5157}
5158
5159/// Build the CSV table for `content`, sniffing the dialect and the header the
5160/// way upstream's `render_csv` does.
5161///
5162/// `fallback_delimiter` is the dialect to use when `csv.Sniffer` raises, and
5163/// `None` means upstream has none to offer: its `except csv.Error` arm falls
5164/// back to `csv.get_dialect("excel")` for a resource whose name ends `.csv`
5165/// and to `"excel-tab"` for one ending `.tsv`, and for **everything else** —
5166/// any other extension, a bare name, a URL, stdin — calls
5167/// `on_error(str(error))`, which prints the message and exits non-zero.
5168///
5169/// So `None` here is a failure, and the caller must report it. Rendering a
5170/// fabricated one-column table at exit 0 instead is the worst possible
5171/// outcome: `rich --csv "$f" && publish` proceeds on a file nothing could
5172/// parse, with empty stderr to say so.
5173///
5174/// `infer` (`--infer`, not upstream) types each column with
5175/// `rich_data::infer` after `--head`/`--tail` cut the rows; see
5176/// [`render_csv`].
5177fn build_csv_table(
5178    content: &str,
5179    fallback_delimiter: Option<char>,
5180    title: Option<&str>,
5181    caption: Option<&str>,
5182    window: (Option<usize>, Option<usize>),
5183    infer: bool,
5184) -> Option<Table> {
5185    // The sniffer sees only the first 1024 *characters* — upstream's
5186    // `csv_data[:1024]` — however long the file is.
5187    let sample: String = content.chars().take(1024).collect();
5188    let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5189    let (dialect, header) = match sniffed {
5190        Some(sniffed) => sniffed,
5191        None => (Dialect::excel(fallback_delimiter?), true),
5192    };
5193    let mut rows = read_rows(content, &dialect);
5194    // `table_rows[:head]` or `table_rows[-tail:]` (head wins), counted over
5195    // the non-blank data rows, before the numeric columns are chosen.
5196    if window.0.is_some() || window.1.is_some() {
5197        let header_row = if header && !rows.is_empty() {
5198            Some(rows.remove(0))
5199        } else {
5200            None
5201        };
5202        let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5203        match window {
5204            (Some(head), _) => data.truncate(head),
5205            (None, Some(tail)) => {
5206                data.drain(..data.len().saturating_sub(tail));
5207            }
5208            (None, None) => {}
5209        }
5210        rows = header_row.into_iter().chain(data).collect();
5211    }
5212    let types = infer.then(|| infer_csv_types(&rows, header));
5213    Some(render_csv(rows, header, title, caption, types))
5214}
5215
5216/// Each column's inferred type, for `--infer`: the data rows (blank lines
5217/// skipped, as the table skips them) read by `rich_data::infer`, a missing
5218/// cell in a short row counting as null.
5219fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5220    let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5221    let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5222    for row in rows
5223        .iter()
5224        .skip(usize::from(has_header))
5225        .filter(|row| !row.is_empty())
5226    {
5227        data.push(
5228            (0..width)
5229                .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5230        );
5231    }
5232    Inferrer::new()
5233        .infer(&data)
5234        .columns()
5235        .iter()
5236        .map(|column| column.data_type())
5237        .collect()
5238}
5239
5240/// The dialect upstream falls back to when `csv.Sniffer` cannot read
5241/// `resource`, or `None` when it has none — a failure, not a default.
5242///
5243/// Upstream tests the **resource string**, not the detected language:
5244/// `resource.lower().endswith(".csv")` then `.endswith(".tsv")`. So a URL
5245/// ending `.tsv` gets the tab dialect, and `-` (stdin) gets nothing.
5246fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5247    let name = resource.unwrap_or_default().to_lowercase();
5248    if name.ends_with(".csv") {
5249        Some(',')
5250    } else if name.ends_with(".tsv") {
5251        Some('\t')
5252    } else {
5253        None
5254    }
5255}
5256
5257/// Build a table from parsed CSV `rows`, mirroring rich-cli's `render_csv`: a
5258/// blue border, `HEAVY_HEAD` when the sniffer found a header and `SQUARE` when
5259/// it did not, and any all-numeric column right-justified with bold-green body
5260/// and header cells.
5261///
5262/// With `types` (`--infer`), a column is numeric when its inferred type is
5263/// an integer or a float, so null tokens (`NA`, `null`, …) no longer make a
5264/// number column text, and each heading shows its type on a second, dim
5265/// line; a table without a header row then shows a heading of types alone.
5266fn render_csv(
5267    rows: Vec<Vec<String>>,
5268    has_header: bool,
5269    title: Option<&str>,
5270    caption: Option<&str>,
5271    types: Option<Vec<InferredType>>,
5272) -> Table {
5273    let mut table = Table::new()
5274        .border_style(Style::parse("blue").expect("valid style"))
5275        .show_header(has_header || types.is_some())
5276        .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5277    if let Some(title) = title {
5278        table = table.title(title);
5279    }
5280    if let Some(caption) = caption {
5281        table = table.caption(caption);
5282    }
5283
5284    let empty: Vec<String> = Vec::new();
5285    let (header, body) = if has_header {
5286        match rows.split_first() {
5287            Some((header, body)) => (header, body),
5288            None => return table,
5289        }
5290    } else {
5291        (&empty, rows.as_slice())
5292    };
5293    // `[row for row in rows if row]`: a blank line is not a table row.
5294    let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5295
5296    // A row may carry more fields than the header names, and upstream's
5297    // `table.add_row(*row)` grows the table to hold them. Columns came from the
5298    // header alone, so those fields had nowhere to go and were dropped -- a
5299    // silent data loss in a tool whose job is showing you the file.
5300    let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5301    let columns = header.len().max(widest);
5302
5303    for index in 0..columns {
5304        // A column is numeric when no data cell is a non-empty non-number. A
5305        // row too SHORT to reach the column disqualifies it (upstream's
5306        // `except Exception: break`), while an empty cell does not; and an
5307        // empty data set counts as numeric, as upstream's `for … else` does.
5308        let inferred = types
5309            .as_ref()
5310            .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5311        let numeric = match inferred {
5312            Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5313            None => data.iter().all(|row| match row.get(index) {
5314                Some(value) => value.is_empty() || is_number(value),
5315                None => false,
5316            }),
5317        };
5318        let name = header.get(index).map(String::as_str).unwrap_or("");
5319        if let Some(inferred) = inferred {
5320            let mut heading = Text::new(name);
5321            if !name.is_empty() {
5322                heading.append("\n", None);
5323            }
5324            heading.append(
5325                inferred.name(),
5326                Some(Style::parse("dim").expect("valid style").into()),
5327            );
5328            let justify = if numeric {
5329                Justify::Right
5330            } else {
5331                Justify::Left
5332            };
5333            table.add_column_text(heading, justify);
5334        } else if numeric {
5335            table.add_column_justify(name, Justify::Right);
5336        } else {
5337            table.add_column(name);
5338        }
5339        if numeric {
5340            table.column_style(Style::parse("bold green").expect("valid style"));
5341            table.column_header_fill(Style::parse("bold green").expect("valid style"));
5342        }
5343    }
5344    // Measurement/style inference is complete. Move the parsed cells into the
5345    // table rather than retaining and cloning the complete input row set.
5346    drop(data);
5347    table.extend_owned_rows(
5348        rows.into_iter()
5349            .skip(usize::from(has_header))
5350            .filter(|row| !row.is_empty())
5351            .collect(),
5352    );
5353    table
5354}
5355
5356/// Join a notebook cell/output `source` field (a JSON string, or an array of
5357/// line strings that already include their trailing newlines).
5358fn join_source(value: &serde_json::Value) -> String {
5359    match value {
5360        serde_json::Value::String(s) => s.clone(),
5361        serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5362        _ => String::new(),
5363    }
5364}
5365
5366/// Join a notebook `traceback`, whose elements are lines WITHOUT trailing
5367/// newlines (unlike `source`/`text`, which carry their own). Using
5368/// [`join_source`] here fused a whole stack trace onto one line.
5369fn join_traceback(value: &serde_json::Value) -> String {
5370    match value {
5371        serde_json::Value::String(s) => s.clone(),
5372        serde_json::Value::Array(a) => a
5373            .iter()
5374            .filter_map(|v| v.as_str())
5375            .collect::<Vec<_>>()
5376            .join(
5377                "
5378",
5379            ),
5380        _ => String::new(),
5381    }
5382}
5383
5384/// Build an `In [n]:` / `Out[n]:` execution-count label. Port of rich-cli's
5385/// `[green]In [[#66ff00]{count}[/#66ff00]]:[/green]` markup (built as spans to
5386/// avoid markup-escaping the literal brackets).
5387fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5388    let base_style = Style::parse(base).ok();
5389    let number_style = Style::parse(number).ok();
5390    let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5391    let mut text = Text::new("");
5392    text.append(&format!("{word}["), base_style.clone().map(Into::into));
5393    text.append(&n, number_style.map(Into::into));
5394    text.append("]:", base_style.map(Into::into));
5395    text
5396}
5397
5398/// Port of the Group returned by rich-cli's `render_ipynb`. Keeping the cells
5399/// composable lets the common padding/panel/style/width/alignment chain apply.
5400struct Notebook {
5401    items: Vec<Box<dyn Renderable>>,
5402    // Console.print(justify=...) also reaches the group's Text children in
5403    // upstream. Their individual lines align within the group's measured width.
5404    justify: Option<Justify>,
5405}
5406
5407impl Renderable for Notebook {
5408    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5409        let mut options = options.clone();
5410        if let Some(justify) = self.justify {
5411            options.justify = justify;
5412        }
5413        let mut segments = Vec::new();
5414        for item in &self.items {
5415            let rendered = item.rich_render(console, &options);
5416            if !rendered.is_empty() {
5417                segments.extend(rendered);
5418                segments.push(Segment::line());
5419            }
5420        }
5421        // Console supplies the final newline. Empty groups produce no output.
5422        if !segments.is_empty() {
5423            segments.pop();
5424        }
5425        segments
5426    }
5427
5428    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5429        self.items
5430            .iter()
5431            .map(|item| item.measure(console, options))
5432            .fold(Measurement::new(0, 0), |width, item| {
5433                Measurement::new(
5434                    width.minimum.max(item.minimum),
5435                    width.maximum.max(item.maximum),
5436                )
5437            })
5438    }
5439}
5440
5441/// Text.from_ansi joins decoded lines, preserving their style spans.
5442fn notebook_ansi(source: &str) -> Text {
5443    let mut result = Text::new("");
5444    for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5445        if index > 0 {
5446            result.append("\n", None);
5447        }
5448        result = result.append_text(line);
5449    }
5450    result
5451}
5452
5453/// Port of rich-cli 1.8.1's notebook cell/output grouping. Parse once, then use
5454/// the same renderable for terminal output, paging and recorded exports.
5455fn build_ipynb(
5456    notebook: &serde_json::Value,
5457    hyperlinks: bool,
5458    justify: Option<Justify>,
5459    source_options: &SourceOptions,
5460) -> Result<Notebook, String> {
5461    let language = notebook["metadata"]["kernelspec"]["language"]
5462        .as_str()
5463        .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5464        .unwrap_or("python");
5465    let empty = Vec::new();
5466    let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5467    let mut new_line = true;
5468    for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5469        if new_line {
5470            items.push(text(""));
5471        }
5472        if cell.get("execution_count").is_some() {
5473            items.push(Box::new(io_label(
5474                "In ",
5475                cell["execution_count"].as_i64().filter(|n| *n != 0),
5476                "green",
5477                "#66ff00",
5478            )));
5479        }
5480        let source = join_source(&cell["source"]);
5481        items.push(match cell["cell_type"].as_str().unwrap_or("") {
5482            "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5483            // Upstream's `Syntax(source, lexer, line_numbers=…, indent_guides=…,
5484            // word_wrap=not no_wrap, line_range=…)`, per cell.
5485            "code" => Box::new(
5486                Panel::new(Box::new(source_options.syntax(&source, language)?))
5487                    .border_style(Style::parse("dim").expect("valid style")),
5488            ),
5489            _ => text(&source),
5490        });
5491        new_line = true;
5492        for output in cell["outputs"].as_array().unwrap_or(&empty) {
5493            let rendered = match output["output_type"].as_str().unwrap_or("") {
5494                "stream" => {
5495                    new_line = false;
5496                    notebook_ansi(&join_source(&output["text"]))
5497                }
5498                "error" => {
5499                    new_line = true;
5500                    notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5501                }
5502                "execute_result" => {
5503                    new_line = true;
5504                    let mut label = io_label(
5505                        "Out",
5506                        output["execution_count"].as_i64().filter(|n| *n != 0),
5507                        "red",
5508                        "#ee4b2b",
5509                    );
5510                    label.append("\n", None);
5511                    label.append_text(&notebook_ansi(&join_source(&output["data"]["text/plain"])))
5512                }
5513                _ => continue,
5514            };
5515            items.push(Box::new(rendered));
5516        }
5517    }
5518    Ok(Notebook { items, justify })
5519}
5520
5521fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5522    // Use the formatter's rounding for BOTH displayed operands, including
5523    // ties. f32::round uses a different tie rule from the report's formatting.
5524    let displayed = |value: f32| {
5525        format!("{value:.1}")
5526            .parse::<f32>()
5527            .expect("formatted percentage is a number")
5528    };
5529    displayed(changed) > displayed(limit)
5530}
5531
5532/// `rich diff` for anything but an image pair: text, ANSI captures and
5533/// source side by side or unified, or one patch (`git diff | rich diff -`).
5534fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5535    let mut contents = Vec::with_capacity(cli.resources.len());
5536    for resource in &cli.resources {
5537        let content = if is_url(resource) {
5538            fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5539        } else {
5540            read_resource(Some(resource), cli.extensions.encoding)
5541                .map_err(|err| format!("cannot read {resource}: {err}"))
5542        };
5543        match content {
5544            Ok(content) => contents.push(content),
5545            Err(err) => return fail(cli, ExitClass::Input, err),
5546        }
5547    }
5548    let names: Vec<String> = cli
5549        .resources
5550        .iter()
5551        .map(|r| {
5552            if r == "-" {
5553                "<stdin>".to_string()
5554            } else {
5555                r.clone()
5556            }
5557        })
5558        .collect();
5559    let outcome = match tools::text_diff(
5560        &cli.tools,
5561        &names,
5562        &contents,
5563        cli.diff_threshold,
5564        cli.sanitize,
5565    ) {
5566        Ok(outcome) => outcome,
5567        Err(err) => return fail(cli, ExitClass::Data, err),
5568    };
5569    let fit = outcome
5570        .renderable
5571        .measure(console, &console.options())
5572        .maximum;
5573    let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5574    if code != ExitCode::SUCCESS || !outcome.failed {
5575        return code;
5576    }
5577    fail(cli, ExitClass::Gate, "diff threshold exceeded")
5578}
5579
5580/// Compare two images perceptually and report where they differ.
5581///
5582/// Prints a heat map of the ΔE field plus a ranked table of changed regions.
5583/// The table is the point: it is text, so it survives a pipe, a log, and a CI
5584/// transcript, which a picture does not.
5585///
5586/// With `--threshold`, exits non-zero when the changed percentage exceeds it,
5587/// which is what makes this usable as a visual-regression gate.
5588/// Open an image file, translating a few common failure modes (a directory
5589/// instead of a file, an unrecognized extension) into one actionable message.
5590/// Shared by `--diff` (which loads two images) and `--image` (which loads
5591/// one) so neither reimplements the other's error handling.
5592///
5593/// The directory check reached the plain read path but not this one, so
5594/// `rich --diff a.png somedir` still reported "Access is denied".
5595#[cfg(feature = "art")]
5596fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5597    if fs_path(path).is_dir() {
5598        return Err(format!("cannot read {path}: is a directory, not a file"));
5599    }
5600    match rich_art::image::open(fs_path(path)) {
5601        Ok(image) => Ok(image),
5602        Err(err) => {
5603            use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5604            let hint = match &err {
5605                rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5606                    UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5607                        Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5608                    _ => None,
5609                },
5610                _ => None,
5611            };
5612            Err(format!(
5613                "cannot read {path}: {}",
5614                hint.unwrap_or_else(|| err.to_string())
5615            ))
5616        }
5617    }
5618}
5619
5620/// Map the CLI's own `ImageMode` (which also carries `None`, meaning "print no
5621/// picture at all") onto `rich_art`'s `ImageMode` (which has no such variant ?
5622/// a facade that always draws something has no notion of "nothing"). Argument
5623/// parsing rejects `--image-mode none` together with `--image` before this can
5624/// ever see it.
5625#[cfg(feature = "art")]
5626fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5627    match mode {
5628        ImageMode::Auto => rich_art::ImageMode::Auto,
5629        ImageMode::Sixel => rich_art::ImageMode::Sixel,
5630        ImageMode::Blocks => rich_art::ImageMode::Blocks,
5631        ImageMode::Braille => rich_art::ImageMode::Braille,
5632        ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5633        ImageMode::Ascii => rich_art::ImageMode::Ascii,
5634        ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
5635    }
5636}
5637
5638/// Render one still image through rich-art's [`rich_art::ImageArt`] facade.
5639///
5640/// This function only opens the file and maps CLI flags onto the facade's
5641/// builder; picking a backend, sizing and colour handling all stay inside
5642/// [`rich_art::ImageArt`]; nothing here reimplements that logic (see
5643/// AGENTS.md's mirror/ext boundary).
5644#[cfg(feature = "art")]
5645fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5646    use rich_art::ImageArt;
5647
5648    let Some(resource) = cli.resource.as_deref() else {
5649        return fail(
5650            cli,
5651            ExitClass::Usage,
5652            "--image requires an image file: rich --image photo.png (or `-` for stdin)",
5653        );
5654    };
5655
5656    let image = if resource == "-" {
5657        let mut buffer = Vec::new();
5658        if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
5659            return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
5660        }
5661        match rich_art::image::load_from_memory(&buffer) {
5662            Ok(image) => image,
5663            Err(err) => {
5664                return fail(
5665                    cli,
5666                    ExitClass::Input,
5667                    format!("cannot decode image from stdin: {err}"),
5668                )
5669            }
5670        }
5671    } else {
5672        match open_image_path(resource) {
5673            Ok(image) => image,
5674            Err(message) => return fail(cli, ExitClass::Input, message),
5675        }
5676    };
5677
5678    let width = cli.width.unwrap_or_else(|| console.width());
5679    let mut art = ImageArt::new(image)
5680        .transforms(rich_art::ImageTransforms {
5681            rotation: match cli.image_rotate {
5682                Some(90) => rich_art::Rotation::Clockwise90,
5683                Some(180) => rich_art::Rotation::Clockwise180,
5684                Some(270) => rich_art::Rotation::Clockwise270,
5685                _ => rich_art::Rotation::None,
5686            },
5687            flip_horizontal: cli.image_flip_horizontal,
5688            flip_vertical: cli.image_flip_vertical,
5689            grayscale: cli.image_grayscale,
5690            brightness: cli.image_brightness.unwrap_or(1.0),
5691            contrast: cli.image_contrast.unwrap_or(1.0),
5692            gamma: cli.image_gamma.unwrap_or(1.0),
5693        })
5694        .mode(to_art_image_mode(cli.image_mode))
5695        .width(width)
5696        .color(!cli.no_color);
5697    if let Some(height) = cli.height {
5698        art = art.height(height);
5699    }
5700    if let Some(columns) = cli.image_max_width {
5701        art = art.max_width(columns);
5702    }
5703    if let Some(rows) = cli.image_max_height {
5704        art = art.max_height(rows);
5705    }
5706
5707    if let Some(fit) = cli.image_fit.as_deref() {
5708        art = art.fit(match fit {
5709            "cover" => rich_art::ImageFit::Cover,
5710            "stretch" => rich_art::ImageFit::Stretch,
5711            "native" => rich_art::ImageFit::Native,
5712            _ => rich_art::ImageFit::Contain,
5713        });
5714    }
5715    if let Some(anchor) = cli.image_anchor.as_deref() {
5716        use rich_art::ImageAnchor;
5717        art = art.anchor(match anchor {
5718            "top" => ImageAnchor::Top,
5719            "bottom" => ImageAnchor::Bottom,
5720            "left" => ImageAnchor::Left,
5721            "right" => ImageAnchor::Right,
5722            "top-left" => ImageAnchor::TopLeft,
5723            "top-right" => ImageAnchor::TopRight,
5724            "bottom-left" => ImageAnchor::BottomLeft,
5725            "bottom-right" => ImageAnchor::BottomRight,
5726            _ => ImageAnchor::Center,
5727        });
5728    }
5729    match cli.image_background {
5730        Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
5731        Some(ImageBackdrop::Terminal) => {
5732            art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
5733        }
5734        Some(ImageBackdrop::Checkerboard) => {
5735            art = art.background_mode(rich_art::ImageBackground::Checkerboard)
5736        }
5737        None => {}
5738    }
5739    let (color_mode, dither, distance) = image_color_processing(cli);
5740    art = art
5741        .color_mode(color_mode)
5742        .dither(dither)
5743        .color_distance(distance);
5744
5745    // Validate up front: `Renderable::rich_render` cannot fail and would
5746    // silently fall back to ASCII, which is the wrong answer for a CLI that
5747    // promised a clear error (e.g. `--image-mode sixel` on a build without
5748    // the `sixel` feature, or an image Sixel cannot encode).
5749    let options = console.options();
5750    if let Err(err) = art.render(console, &options) {
5751        // rich-art reports an unrecognised terminal as `SixelNotSupported`;
5752        // this covers a console that is a terminal where the art render
5753        // target decided it was not (e.g. a forced width on a pipe).
5754        let message = match err {
5755            rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
5756                "this terminal (TERM={}) is not known to support Sixel graphics; set \
5757                 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
5758                 --image-mode blocks, braille or ascii",
5759                std::env::var("TERM").unwrap_or_default()
5760            ),
5761            err => err.to_string(),
5762        };
5763        return fail(cli, ExitClass::Input, message);
5764    }
5765
5766    let terminal_only = Export {
5767        html_path: None,
5768        svg_path: None,
5769        ..*export
5770    };
5771    if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
5772        return fail(cli, ExitClass::Input, message);
5773    }
5774    if export.html_path.is_some() || export.svg_path.is_some() {
5775        use rich::protocol::RenderEnvironment;
5776        use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
5777        let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
5778        caps.color_system = if cli.no_color {
5779            None
5780        } else {
5781            Some(ColorSystem::Truecolor)
5782        };
5783        let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
5784        let export_console = target.console();
5785        if let Err(err) =
5786            art.render_with_environment(&export_console, &export_console.options(), &target)
5787        {
5788            return fail(cli, ExitClass::Input, err.to_string());
5789        }
5790        let segments = target.segments(&art);
5791        debug_assert!(!target.capabilities().interactive);
5792        if let Err(message) = save_exports(&export_console, export, &segments) {
5793            return fail(cli, ExitClass::Input, message);
5794        }
5795    }
5796    success(cli)
5797}
5798
5799#[cfg(feature = "art")]
5800fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5801    use rich::Table;
5802    use rich_art::imagediff::{diff, DiffSettings};
5803    use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
5804
5805    let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
5806    let before = match open_image_path(before_path) {
5807        Ok(image) => image,
5808        Err(message) => return fail(cli, ExitClass::Input, message),
5809    };
5810    let after = match open_image_path(after_path) {
5811        Ok(image) => image,
5812        Err(message) => return fail(cli, ExitClass::Input, message),
5813    };
5814
5815    let report = match diff(&before, &after, &DiffSettings::default()) {
5816        Ok(report) => report,
5817        Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
5818    };
5819
5820    let width = cli.width.unwrap_or_else(|| console.width());
5821    let changed = report.changed_fraction * 100.0;
5822    let naive = report.naive_changed_fraction * 100.0;
5823
5824    // Decided before rendering so the verdict can be part of the output (and
5825    // therefore part of an --export-svg capture), while the exit code is
5826    // settled outside the closure.
5827    // Compare the number that is PRINTED, not the one behind it. Comparing full
5828    // precision against a one-decimal display produced "FAIL 5.4% changed, limit
5829    // 5.4%" -- a verdict that contradicts itself and cannot be explained from
5830    // the output, which is what anyone tuning a threshold to the reported figure
5831    // runs straight into.
5832    let failed = cli
5833        .diff_threshold
5834        .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
5835
5836    let render_report = |c: &Console, for_export: bool| {
5837        // Leave room for the summary, the table and the prompt, so the top of
5838        // the picture is not scrolled off before the reader sees it.
5839        let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
5840
5841        // Resolve `auto` from what this console can ACTUALLY do, not from the
5842        // --no-color flag alone. Redirected output has no colour, and a
5843        // half-block render without colour is a solid rectangle of identical
5844        // characters -- 30 rows carrying no information. Sixel is a control
5845        // sequence, so it is dropped entirely when the destination is not a
5846        // terminal.
5847        let has_color = c.color_system().is_some() && !cli.no_color;
5848        let is_terminal = c.is_terminal();
5849        let mut mode = cli.image_mode;
5850        if mode == ImageMode::Auto {
5851            if !has_color && !for_export && cli.report_format == ReportFormat::Human {
5852                eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
5853            }
5854            mode = if !has_color {
5855                ImageMode::Ascii
5856            } else if is_terminal && rich_art::sixel::is_probably_supported() {
5857                ImageMode::Sixel
5858            } else {
5859                ImageMode::Blocks
5860            };
5861        }
5862
5863        // An explicit choice still has to produce something. Downgrade rather
5864        // than emit a rectangle of identical blocks or nothing at all, and say
5865        // why on stderr so the change is visible rather than mysterious.
5866        if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
5867            if !for_export && cli.report_format == ReportFormat::Human {
5868                eprintln!("rich: no colour available, drawing the diff as ASCII art");
5869            }
5870            mode = ImageMode::Ascii;
5871        }
5872        if mode == ImageMode::Sixel && !is_terminal {
5873            if cli.image_mode != ImageMode::Sixel
5874                && !for_export
5875                && cli.report_format == ReportFormat::Human
5876            {
5877                eprintln!(
5878                    "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
5879                    if has_color { "blocks" } else { "ASCII art" }
5880                );
5881            }
5882            mode = if has_color {
5883                ImageMode::Blocks
5884            } else {
5885                ImageMode::Ascii
5886            };
5887        }
5888
5889        match mode {
5890            ImageMode::None => {}
5891            ImageMode::Ascii => c.print(
5892                &AsciiArt::new(report.heatmap())
5893                    .width(width)
5894                    .color(has_color),
5895            ),
5896            ImageMode::Blocks => c.print(
5897                &BlockArt::new(report.heatmap())
5898                    .width(width)
5899                    .height(rows_cap),
5900            ),
5901            ImageMode::Quadrants => c.print(
5902                &rich_art::QuadrantArt::new(report.heatmap())
5903                    .width(width)
5904                    .height(rows_cap),
5905            ),
5906            ImageMode::Braille => c.print(
5907                &BrailleArt::new(report.heatmap())
5908                    .width(width)
5909                    .height(rows_cap),
5910            ),
5911            ImageMode::Sixel => {
5912                let art = SixelArt::new(report.heatmap())
5913                    .width(width)
5914                    .height(rows_cap);
5915                // Encoding can still fail on a terminal; the report must stay
5916                // useful, so fall back rather than leaving an empty gap.
5917                if art.encode(width).is_some() {
5918                    c.print(&art);
5919                } else {
5920                    if cli.report_format == ReportFormat::Human {
5921                        eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
5922                    }
5923                    c.print(
5924                        &BlockArt::new(report.heatmap())
5925                            .width(width)
5926                            .height(rows_cap),
5927                    );
5928                }
5929            }
5930            ImageMode::Auto => unreachable!("resolved above"),
5931        }
5932        // The blank line separates the picture from the summary, so it belongs
5933        // to the picture — with --image-mode none it was just a stray first
5934        // line in every report and every redirected file.
5935        if mode != ImageMode::None {
5936            c.print_str("");
5937        }
5938        c.print_str(&format!(
5939            "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
5940             [dim](a plain pixel diff would say {naive:.1}%)[/]"
5941        ));
5942
5943        if report.regions.is_empty() {
5944            c.print_str("[dim]No region large enough to report.[/]");
5945        } else {
5946            let mut table = Table::new().title("Where it changed");
5947            table.add_column("#");
5948            table.add_column_justify("Share", rich::Justify::Right);
5949            table.add_column_justify("Mean ΔE", rich::Justify::Right);
5950            table.add_column_justify("Area px", rich::Justify::Right);
5951            table.add_column("Box (x,y w×h)");
5952            for (rank, r) in report.regions.iter().enumerate() {
5953                table.add_row(&[
5954                    &format!("{}", rank + 1),
5955                    &format!("{:.0}%", r.share_of_change * 100.0),
5956                    &format!("{:.1}", r.mean_delta_e),
5957                    &format!("{}", r.area_px),
5958                    &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
5959                ]);
5960            }
5961            c.print(&table);
5962        }
5963
5964        // The gate is compared against the perceptual figure, never the naive
5965        // one — gating on a pixel diff is what makes visual regression testing
5966        // useless in the first place.
5967        if let Some(limit) = cli.diff_threshold {
5968            if failed {
5969                c.print_str(&format!(
5970                    "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
5971                ));
5972            } else {
5973                c.print_str(&format!(
5974                    "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
5975                ));
5976            }
5977        }
5978    };
5979
5980    let terminal_only = Export {
5981        html_path: None,
5982        svg_path: None,
5983        ..*export
5984    };
5985    let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
5986    if export.html_path.is_some() || export.svg_path.is_some() {
5987        // HTML/SVG support truecolor independently of stdout. Render the already
5988        // loaded report for that destination; never reread images or recompute
5989        // the comparison. A nonterminal console prevents Sixel control data.
5990        let mut export_console = Console::builder()
5991            .force_terminal(false)
5992            .color_system(Some(ColorSystem::Truecolor))
5993            .no_color(cli.no_color)
5994            .width(console.width())
5995            .height(console.height())
5996            .theme(console.theme().clone())
5997            .build();
5998        export_console.install_extensions();
5999        let segments = export_console.record_output(|c| render_report(c, true));
6000        wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6001    }
6002
6003    if let Err(message) = wrote {
6004        return fail(cli, ExitClass::Input, message);
6005    }
6006    if failed {
6007        return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6008    }
6009    success(cli)
6010}
6011
6012/// Animate every `--gif` resource at once, sharing the console width.
6013/// `--image-background`: what transparent pixels become.
6014#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6015#[cfg_attr(not(feature = "art"), allow(dead_code))]
6016enum ImageBackdrop {
6017    /// `#RRGGBB`: composite onto this colour.
6018    Color([u8; 3]),
6019    /// `default`: leave transparent cells to the terminal's background.
6020    Terminal,
6021    /// `checkerboard`: composite onto a gray checkerboard.
6022    Checkerboard,
6023}
6024
6025impl ImageBackdrop {
6026    fn parse(value: &str) -> Option<Self> {
6027        match value {
6028            "default" => return Some(ImageBackdrop::Terminal),
6029            "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6030            _ => {}
6031        }
6032        let hex = value.strip_prefix('#')?;
6033        if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6034            return None;
6035        }
6036        let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6037        Some(ImageBackdrop::Color([
6038            channel(0)?,
6039            channel(1)?,
6040            channel(2)?,
6041        ]))
6042    }
6043}
6044
6045/// `--image-color`, `--image-dither` and `--image-color-distance`, shared by
6046/// still images and GIF frames. Unset options are the library defaults.
6047#[cfg(feature = "art")]
6048fn image_color_processing(
6049    cli: &Cli,
6050) -> (
6051    rich_art::ImageColorMode,
6052    rich_art::Dither,
6053    rich_art::ColorDistance,
6054) {
6055    use rich_art::{ColorDistance, Dither, ImageColorMode};
6056    let mode = match cli.image_color.as_deref() {
6057        Some("ansi256") => ImageColorMode::Ansi256,
6058        Some("ansi16") => ImageColorMode::Ansi16,
6059        Some("grayscale") => ImageColorMode::Grayscale,
6060        _ => ImageColorMode::TrueColor,
6061    };
6062    let dither = match cli.image_dither.as_deref() {
6063        Some("floyd-steinberg") => Dither::FloydSteinberg,
6064        Some("bayer4x4") => Dither::Bayer4x4,
6065        Some("atkinson") => Dither::Atkinson,
6066        _ => Dither::None,
6067    };
6068    let distance = match cli.image_color_distance.as_deref() {
6069        Some("oklab") => ColorDistance::Oklab,
6070        _ => ColorDistance::Rgb,
6071    };
6072    (mode, dither, distance)
6073}
6074
6075#[cfg(feature = "art")]
6076fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6077    use rich_art::{AnimatedArt, Repeat, Stage};
6078
6079    if cli.resources.is_empty() {
6080        return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6081    }
6082    let count = cli.resources.len();
6083    const GAP: usize = 2;
6084
6085    // Share the width between the animations, leaving room for the gaps.
6086    let total = cli.width.unwrap_or_else(|| console.width());
6087    let per_gif = total
6088        .saturating_sub(GAP * count.saturating_sub(1))
6089        .checked_div(count)
6090        .unwrap_or(total)
6091        .max(8);
6092
6093    let repeat = match cli.loops {
6094        Some(0) => Repeat::Forever,
6095        Some(n) => Repeat::Times(n),
6096        None => Repeat::Times(1),
6097    };
6098
6099    let mut stage = Stage::new().gap(GAP);
6100    for path in &cli.resources {
6101        match AnimatedArt::from_path(path) {
6102            Ok(art) => {
6103                let (color_mode, dither, distance) = image_color_processing(cli);
6104                stage = stage.with(
6105                    art.width(per_gif)
6106                        .blocks(cli.extensions.gif_blocks())
6107                        .color(!cli.no_color)
6108                        .color_mode(color_mode)
6109                        .dither(dither)
6110                        .color_distance(distance)
6111                        .repeat(repeat)
6112                        // Colour art is byte-heavy; keep it comfortable.
6113                        .max_fps(30.0),
6114                );
6115            }
6116            Err(err) => {
6117                return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6118            }
6119        }
6120    }
6121
6122    if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6123        eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6124    }
6125
6126    // `play` needs its own console (it moves into the Live display).
6127    let mut builder = Console::builder().no_color(cli.no_color);
6128    if cli.themed() {
6129        builder = builder.theme(cli_theme(cli));
6130    }
6131    // Parallel workers spool output, but render for the parent's destination.
6132    // Pager policy still checks the real stdout handle before starting a pager.
6133    if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6134        builder = builder.force_terminal(terminal == "1");
6135    }
6136    if let Some(width) = cli.width {
6137        builder = builder.width(width);
6138    }
6139    match stage.play_stdout(builder.build()) {
6140        Ok(()) => success(cli),
6141        Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6142    }
6143}
6144
6145/// Without the `art` feature there is no GIF support.
6146#[cfg(not(feature = "art"))]
6147fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6148    fail(
6149        _cli,
6150        ExitClass::Usage,
6151        "this build has no GIF support (rebuild with the `art` feature)",
6152    )
6153}
6154
6155/// Where a render action's output goes: straight to the terminal, or captured
6156/// into a self-contained HTML or SVG document.
6157struct Export<'a> {
6158    /// `--export-html PATH`, if given.
6159    html_path: Option<&'a str>,
6160    /// `--export-svg PATH`, if given.
6161    svg_path: Option<&'a str>,
6162    /// Document title for the SVG frame (the resource's file name).
6163    svg_title: &'a str,
6164    /// `--pager`: page the terminal output instead of writing it straight out.
6165    pager: bool,
6166    auto_pager: bool,
6167}
6168
6169impl Export<'_> {
6170    /// Whether the output goes straight to the terminal and nowhere else: no
6171    /// export, no pager, no watch capture.
6172    fn terminal_only(&self) -> bool {
6173        !watch::capturing()
6174            && self.html_path.is_none()
6175            && self.svg_path.is_none()
6176            && !self.pager
6177            && !self.auto_pager
6178    }
6179}
6180
6181/// Render once and deliver it everywhere it was asked for.
6182///
6183/// The exports do **not** replace the terminal output — upstream prints the
6184/// resource *and* saves the files, and both `--export-html` and `--export-svg`
6185/// may be given together. So when either is set the render is recorded once and
6186/// the same segments are turned into terminal bytes, HTML and SVG. Diff reports
6187/// use separate destination capabilities after loading/comparing the images;
6188/// ordinary input renderables are recorded once here.
6189///
6190/// Returns false if a file could not be written, so the caller can exit non-zero.
6191fn should_page(
6192    explicit: bool,
6193    automatic: bool,
6194    terminal: bool,
6195    lines: usize,
6196    height: usize,
6197) -> bool {
6198    terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6199}
6200
6201fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6202    if export.terminal_only() {
6203        render(console);
6204        return Ok(());
6205    }
6206    emit_segments(console, export, console.record_output(render))
6207}
6208
6209/// [`emit`] for output already rendered, as `print` would have written it
6210/// (with its final newline).
6211fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6212    if watch::capturing() {
6213        // A live watch region repaints these segments itself.
6214        watch::capture_segments(segments);
6215        return Ok(());
6216    }
6217    let mut first_error = None;
6218
6219    // The terminal still gets the output, exports or not.
6220    let terminal = console.segments_to_string(&segments);
6221    if should_page(
6222        export.pager,
6223        export.auto_pager,
6224        std::io::stdout().is_terminal(),
6225        terminal.lines().count(),
6226        console.height(),
6227    ) {
6228        // The text is already rendered, so hand it straight to the pager
6229        // rather than re-rendering through `Console::page`.
6230        if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6231            first_error = Some(format!("cannot page output: {err}"));
6232        }
6233    } else {
6234        let stdout = std::io::stdout();
6235        let mut lock = stdout.lock();
6236        if let Err(err) = lock.write_all(terminal.as_bytes()) {
6237            first_error = Some(format!("could not write output: {err}"));
6238        }
6239    }
6240
6241    save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6242}
6243
6244fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6245    let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6246    let segments = filtered.as_slice();
6247    let mut first_error = None;
6248    if let Some(path) = export.html_path {
6249        // CSS-class stylesheet form, as upstream's `save_html` default.
6250        let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6251        if let Err(err) = std::fs::write(fs_path(path), html) {
6252            first_error = Some(format!("failed to save HTML: {err}"));
6253        }
6254    }
6255    if let Some(path) = export.svg_path {
6256        let svg = rich::svg::export_svg(
6257            segments,
6258            &rich::SVG_EXPORT_THEME,
6259            export.svg_title,
6260            // Upstream derives this id by hashing Python reprs; a fixed one keeps
6261            // our output deterministic (DIVERGENCES #15).
6262            "rich-cli",
6263            console.width(),
6264        );
6265        if let Err(err) = std::fs::write(fs_path(path), svg) {
6266            first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6267        }
6268    }
6269    first_error.map_or(Ok(()), Err)
6270}
6271
6272/// `--help`: the [`cli_spec`] help, through a stdout console.
6273fn print_help(no_color: bool) {
6274    cli_spec::print_help(no_color);
6275}
6276
6277fn build_demo_console(no_color: bool) -> Console {
6278    // Force truecolor so the demo looks the same regardless of TERM — but only
6279    // when it is actually going to a terminal. Forcing it unconditionally wrote
6280    // escape sequences into a pipe, and made `--no-color` a no-op on this path
6281    // alone while every other mode honoured it.
6282    let to_terminal = std::io::stdout().is_terminal() && !no_color;
6283    let mut console = Console::builder()
6284        .force_terminal(to_terminal)
6285        .no_color(no_color)
6286        .color_system(if to_terminal {
6287            Some(ColorSystem::Truecolor)
6288        } else {
6289            None
6290        })
6291        // `[error]`/`[warning]`/`[info]` are rich-ext's additions, not upstream
6292        // styles — the core theme is a faithful 154-entry port.
6293        .theme(rich_ext::extended_theme())
6294        .build();
6295    console.install_extensions();
6296    console
6297}
6298
6299fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6300    let to_terminal = std::io::stdout().is_terminal() && !no_color;
6301    let console = build_demo_console(no_color);
6302
6303    demo::section(&console, delay, "rs-rich-cli");
6304    console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6305    console.print_str(
6306        "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6307    );
6308
6309    // Markup, color, theme, and the rich-ext highlighter.
6310    demo::section(&console, delay, "markup · color · theme · extension");
6311    console.print_str(
6312        "Styles:   [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6313    );
6314    console.print_str(
6315        "Color:    [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6316    );
6317    console.print_str("Theme:    [error]error[/], [warning]warning[/], [info]info[/]");
6318    // Upstream's theme stack: a pushed theme applies until its guard drops.
6319    let mut themed = build_demo_console(no_color);
6320    let release =
6321        rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6322    themed.use_theme(release).print_str(
6323        "Stack:    [release]release[/] from a pushed theme, popped when its guard drops",
6324    );
6325    console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6326    // Built-in ReprHighlighter (highlight=true): auto-colors numbers, bools,
6327    // strings, paths, calls, etc.
6328    // A second console, so it needs the same terminal/colour decision as the
6329    // main one — building it with force_terminal(true) unconditionally was what
6330    // leaked escape sequences into a pipe after the demo itself stopped.
6331    let hl = Console::builder()
6332        .force_terminal(to_terminal)
6333        .no_color(no_color)
6334        .color_system(if to_terminal {
6335            Some(ColorSystem::Truecolor)
6336        } else {
6337            None
6338        })
6339        .highlight(true)
6340        .build();
6341    hl.print_str("Highlight:  result = func(42, True, None, '/usr/bin')");
6342    // ISO8601Highlighter — colors date/time/timezone fields.
6343    let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6344    ISO8601Highlighter::new().highlight(&mut stamp);
6345    console.print(&Text::new("ISO 8601:   ").append_text(&stamp));
6346    console.print_str("Emoji:     :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6347
6348    // Rule variants (title alignment).
6349    demo::section(&console, delay, "rule");
6350    console.print(&Rule::line());
6351    demo::section(&console, delay, "centered");
6352    console.print(&Rule::new("left").align(HorizontalAlign::Left));
6353    console.print(&Rule::new("right").align(HorizontalAlign::Right));
6354
6355    // Panels with different boxes.
6356    demo::section(&console, delay, "panel");
6357    console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6358    console.print(
6359        &Panel::new(text("square box"))
6360            .box_set(SQUARE)
6361            .title("square"),
6362    );
6363    console.print(
6364        &Panel::new(text("double box"))
6365            .box_set(DOUBLE)
6366            .title("double"),
6367    );
6368    console.print(
6369        &Panel::new(text("title + subtitle"))
6370            .title("top")
6371            .subtitle("bottom"),
6372    );
6373    // Legacy-Windows box substitution: a ROUNDED panel falls back to SQUARE.
6374    let legacy = Console::builder()
6375        .force_terminal(to_terminal)
6376        .no_color(no_color)
6377        .color_system(if to_terminal {
6378            Some(ColorSystem::Truecolor)
6379        } else {
6380            None
6381        })
6382        .width(console.width())
6383        .legacy_windows(true)
6384        .build();
6385    legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6386
6387    // Padding (shown inside a panel so the blank space is visible).
6388    demo::section(&console, delay, "padding");
6389    console.print(
6390        &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6391    );
6392
6393    // Styled — lay one style under an entire renderable.
6394    demo::section(&console, delay, "styled");
6395    console.print(&Styled::new(
6396        Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6397        Style::parse("green").unwrap(),
6398    ));
6399
6400    // Horizontal alignment (fills the console width).
6401    demo::section(&console, delay, "align");
6402    console.print(&Align::left(text("← left")));
6403    console.print(&Align::center(text("center")));
6404    console.print(&Align::right(text("right →")));
6405
6406    // Text justify (via print(justify=…)).
6407    demo::section(&console, delay, "justify");
6408    console.print_justified("left justified", Justify::Left);
6409    console.print_justified("centered", Justify::Center);
6410    console.print_justified("right justified", Justify::Right);
6411
6412    // Constrain — same panel, capped to 24 cells.
6413    demo::section(&console, delay, "constrain (width 24)");
6414    console.print(&Constrain::new(
6415        Box::new(Panel::new(text("constrained")).title("≤24")),
6416        Some(24),
6417    ));
6418
6419    // Table.
6420    demo::section(&console, delay, "table");
6421    let mut table = Table::new().title("ported renderables");
6422    // A styled column and a fixed-width column (content wraps with ellipsis).
6423    table
6424        .add_column("Renderable")
6425        .column_style(Style::parse("cyan").unwrap());
6426    table.add_column("Module").column_width(12);
6427    table.add_column_justify("Parity", Justify::Center);
6428    for (renderable, module, parity) in [
6429        ("Text / markup", "text, markup", "✓"),
6430        ("Rule", "rule", "✓"),
6431        ("Panel", "panel", "✓"),
6432        ("Padding", "padding", "✓"),
6433        ("Align", "align", "✓"),
6434        ("Constrain", "constrain", "✓"),
6435        ("Columns", "columns", "✓"),
6436        ("Table", "table", "✓"),
6437        ("Tree", "tree", "✓"),
6438        ("ProgressBar", "progress_bar", "✓"),
6439    ] {
6440        table.add_row(&[renderable, module, parity]);
6441    }
6442    console.print(&table);
6443
6444    // Columns — packs items into as many columns as fit the width.
6445    demo::section(&console, delay, "columns");
6446    let months = [
6447        "January",
6448        "February",
6449        "March",
6450        "April",
6451        "May",
6452        "June",
6453        "July",
6454        "August",
6455        "September",
6456        "October",
6457        "November",
6458        "December",
6459    ];
6460    console.print(&Columns::new(
6461        months.iter().map(|m| m.to_string()).collect(),
6462    ));
6463
6464    // Tree.
6465    demo::section(&console, delay, "tree");
6466    let mut tree = Tree::new("rs-rich-cli");
6467    let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6468    core.add("color · style · text · console");
6469    core.add("box · rule · panel · padding · align");
6470    core.add("table · tree · wrap · filesize");
6471    tree.add("crates/rich-ext (our extensions)");
6472    tree.add("crates/rich-cli (this binary)");
6473    console.print(&tree);
6474
6475    // Progress bars at a few completion levels (0 → 100%).
6476    demo::section(&console, delay, "progress bar");
6477    for pct in [0.0, 33.0, 66.0, 100.0] {
6478        console.print(&ProgressBar::new(100.0, pct).width(48));
6479    }
6480
6481    // Progress display with upstream's default columns (description, bar,
6482    // percentage, time remaining), driven by a simulated clock so the frame is
6483    // deterministic: speeds and ETAs come from real task samples.
6484    demo::section(&console, delay, "progress");
6485    let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6486    let set_time =
6487        |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6488    let reader = clock.clone();
6489    let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6490    let mut progress = Progress::new().clock(now.clone());
6491    let downloading = progress.add_task("Downloading", 100.0, 0.0);
6492    let processing = progress.add_task("Processing", 100.0, 0.0);
6493    progress.add_task("Waiting", 100.0, 0.0);
6494    set_time(4.0);
6495    progress.advance(downloading, 20.0);
6496    progress.advance(processing, 60.0);
6497    set_time(8.0);
6498    progress.advance(downloading, 30.0);
6499    progress.advance(processing, 40.0);
6500    console.print(&progress);
6501
6502    // Time, rate and spinner columns on a simulated transfer.
6503    let mut transfer = Progress::new().clock(now).columns(vec![
6504        ProgressColumn::spinner(),
6505        ProgressColumn::Description,
6506        ProgressColumn::Bar,
6507        ProgressColumn::Download,
6508        ProgressColumn::TransferSpeed,
6509        ProgressColumn::TimeElapsed,
6510        ProgressColumn::time_remaining(),
6511    ]);
6512    let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6513    let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6514    set_time(12.0);
6515    transfer.advance(iso, 1_200_000_000.0);
6516    transfer.advance(notes, 20_000.0);
6517    set_time(20.0);
6518    transfer.advance(iso, 900_000_000.0);
6519    transfer.advance(notes, 22_000.0);
6520    console.print(&transfer);
6521
6522    // Progress with custom columns — description + bar + M-of-N counter.
6523    let mut mofn = Progress::new().columns(vec![
6524        ProgressColumn::Description,
6525        ProgressColumn::Bar,
6526        ProgressColumn::MofN,
6527    ]);
6528    mofn.add_task("Files", 8.0, 3.0);
6529    mofn.add_task("Chunks", 128.0, 128.0);
6530    console.print(&mofn);
6531
6532    // Progress with a download column — completed/total in shared byte units.
6533    let mut download = Progress::new().columns(vec![
6534        ProgressColumn::Description,
6535        ProgressColumn::Bar,
6536        ProgressColumn::Download,
6537    ]);
6538    download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6539    console.print(&download);
6540
6541    // JSON — pretty-printed and highlighted.
6542    demo::section(&console, delay, "json");
6543    if let Ok(json) = Json::new(
6544        r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6545    ) {
6546        console.print(&json);
6547    }
6548
6549    // Pretty — colorize a Rust value's Debug output (Rust-native rich.pretty).
6550    demo::section(&console, delay, "pretty");
6551    let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6552    console.print(&Pretty::new(&value));
6553
6554    // Log records — upstream's LogRender grid: a repeated time is blanked.
6555    demo::section(&console, delay, "log");
6556    let log = LogRender::new().show_level(true);
6557    for (level, message) in [
6558        (LogLevel::Info, "server started on port 8080"),
6559        (LogLevel::Debug, "cache warm: 128 entries"),
6560        (LogLevel::Warn, "disk usage at 85%"),
6561        (LogLevel::Error, "connection reset by peer"),
6562    ] {
6563        console.print(&log.render(
6564            &console,
6565            Text::new(message),
6566            Some(Text::new("[12:00:00]")),
6567            level.text(),
6568            Some("main.rs"),
6569            Some(42),
6570            None,
6571        ));
6572    }
6573
6574    // Traceback — render an error + its source chain (Rust-native rich.traceback).
6575    demo::section(&console, delay, "traceback");
6576    let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6577    let err = std::io::Error::other(cause);
6578    console.print(&Traceback::new(&err));
6579
6580    // Bar — a filled span within a range (eighth-block resolution).
6581    demo::section(&console, delay, "bar (range)");
6582    for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6583        console.print(&Bar::new(100.0, begin, end).width(48));
6584    }
6585
6586    // Markdown — headings, inline styles, lists, block quotes, tables, and rules.
6587    demo::section(&console, delay, "markdown");
6588    console.print(&Markdown::new(
6589        "# Heading\n\nA paragraph with **bold**, *italic*, `code`, ~~struck~~ text, and a [link](https://example.com).\nTilde runs pair as upstream pairs them: a ~~~x~~~ b.\n\n- bullet item\n- another\n\n1. first\n2. second\n\n> a block quote\n\n| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n\n```rust\nfn main() {\n    println!(\"hi\");\n}\n```\n\n---",
6590    ));
6591
6592    // Syntax highlighting (via syntect — functional, not byte-parity with rich).
6593    demo::section(&console, delay, "syntax");
6594    console.print(&Syntax::new(
6595        "fn main() {\n    let msg = \"hello, rich\";\n    println!(\"{msg}\");\n}",
6596        "rust",
6597    ));
6598
6599    // Control codes — cursor/screen sequences (shown escaped, not executed).
6600    demo::section(&console, delay, "control codes");
6601    let show_escape = |label: &str, control: &Control| {
6602        // Escaped so the sequence is visible (and its `[` isn't read as markup).
6603        let escaped = control
6604            .as_str()
6605            .replace('\x1b', "\\x1b")
6606            .replace('\x07', "\\a")
6607            .replace('\r', "\\r");
6608        console.print(&Text::new(format!("  {label:>14}: {escaped}")));
6609    };
6610    show_escape("clear screen", &Control::clear());
6611    show_escape("home", &Control::home());
6612    show_escape("move (2,-1)", &Control::move_(2, -1));
6613    show_escape("move_to (3,4)", &Control::move_to(3, 4));
6614    show_escape("hide cursor", &Control::show_cursor(false));
6615    show_escape("bell", &Control::bell());
6616    // LiveRender — the in-place redraw sequence for a 3-line render.
6617    let live_render = LiveRender::new(text("a\nb\nc"));
6618    let _ = console.render_to_string(&live_render); // render once to record the shape
6619    show_escape("live refresh", &live_render.position_cursor());
6620    // Live — the full control stream for a two-frame in-place update (captured
6621    // to a buffer so the demo stays static instead of animating).
6622    let live_console = Console::builder()
6623        .force_terminal(true)
6624        .color_system(Some(ColorSystem::Truecolor))
6625        .width(20)
6626        .build();
6627    let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
6628    live.start();
6629    live.update(text("frame two"));
6630    live.stop();
6631    let stream = String::from_utf8_lossy(live.writer())
6632        .replace('\x1b', "\\x1b")
6633        .replace('\r', "\\r")
6634        .replace('\n', "\\n");
6635    console.print(&Text::new(format!("  {:>14}: {stream}", "live stream")));
6636
6637    // ANSI decoder — parse a raw SGR string back into styled Text, then re-render.
6638    demo::section(&console, delay, "ansi decoder");
6639    let raw =
6640        "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
6641    console.print(&Text::new(format!(
6642        "  input:  {}",
6643        raw.replace('\x1b', "\\x1b")
6644    )));
6645    let mut decoder = rich::AnsiDecoder::new();
6646    for line in decoder.decode(raw) {
6647        console.print(&Text::new("  output: ").append_text(&line));
6648    }
6649
6650    // Capture / export — record a rendering to a buffer, then strip its styles.
6651    demo::section(&console, delay, "capture · export_text");
6652    let plain = console.export_text(|c| {
6653        c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
6654    });
6655    console.print(&Text::new("  export_text (styles stripped):"));
6656    for line in plain.lines() {
6657        console.print(&Text::new(format!("    {line}")));
6658    }
6659    // export_html — the inline CSS a style compiles to (via `--export-html`).
6660    let html_rule = Style::parse("bold red")
6661        .unwrap()
6662        .get_html_style(&DEFAULT_TERMINAL_THEME);
6663    console.print(&Text::new(format!(
6664        "  export_html: [bold red] → <span style=\"{html_rule}\">"
6665    )));
6666
6667    // Layout — split a region into ratioed rows/columns; Panel leaves expand
6668    // to fill their region (rendered here at a fixed size).
6669    demo::section(&console, delay, "layout");
6670    let panel_leaf = |title: &str, body: &str| {
6671        Layout::with_renderable(Box::new(
6672            Panel::new(text(body))
6673                .box_set(SQUARE)
6674                .title(title.to_string()),
6675        ))
6676    };
6677    let mut header = Layout::new();
6678    header.split_row(vec![
6679        panel_leaf("left", "pane A"),
6680        panel_leaf("right", "pane B"),
6681    ]);
6682    let mut root = Layout::new();
6683    root.split_column(vec![
6684        header,
6685        Layout::with_renderable(text("footer (fixed size 3)")).size(3),
6686    ]);
6687    let grid = Console::builder()
6688        .force_terminal(true)
6689        .color_system(Some(ColorSystem::Truecolor))
6690        .width(48)
6691        .height(7)
6692        .build();
6693    for line in grid.export_text(|c| c.print(&root)).lines() {
6694        console.print(&Text::new(line));
6695    }
6696
6697    // Spinners — a few frames of each built-in (animation needs a Live loop).
6698    demo::section(&console, delay, "spinner (frames)");
6699    for name in ["dots", "line", "arrow", "simpleDots"] {
6700        let spinner = Spinner::new(name);
6701        let frames: String = (0..6)
6702            .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
6703            .collect::<Vec<_>>()
6704            .join(" ");
6705        console.print_str(&format!("  {name:>11}: {frames}"));
6706    }
6707    // Status — a spinner + message (green frame; animation needs a Live loop).
6708    let status = Status::new("Loading…").renderable().render(0.0);
6709    console.print(&Text::new("       status: ").append_text(&status));
6710
6711    // CSV rendered as a table (blue border, numeric columns bold-green + right).
6712    demo::section(&console, delay, "csv");
6713    let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
6714    if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
6715        console.print(&table);
6716    }
6717
6718    // filesize.
6719    demo::section(&console, delay, "filesize");
6720    for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
6721        console.print_str(&format!("  {bytes:>13} → {}", filesize::decimal(bytes)));
6722    }
6723    console.print(&Rule::line());
6724    console
6725}
6726
6727#[cfg(test)]
6728mod tests {
6729    use super::*;
6730    use rich::ColorSystem;
6731
6732    /// Every render-mode spelling [`mode_flag_alias`] knows, long and short.
6733    const MODE_FLAGS: &[&str] = &[
6734        "--print",
6735        "-p",
6736        "--markdown",
6737        "-m",
6738        "--json",
6739        "-j",
6740        "-J",
6741        "--syntax",
6742        "-x",
6743        "--csv",
6744        "--ipynb",
6745        "--rst",
6746        "--jsonl",
6747        "--ndjson",
6748        "--log",
6749        "--rule",
6750        "-u",
6751        "--image",
6752        "--gif",
6753        "--diff",
6754        "--inspect",
6755        "--ansi-explain",
6756    ];
6757
6758    /// A render-mode flag before a subcommand's word makes the word a
6759    /// resource, for every subcommand scanner: `rich -p plugins` prints it.
6760    #[test]
6761    fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
6762        for flag in MODE_FLAGS {
6763            assert!(mode_flag_alias(flag).is_some(), "{flag}");
6764        }
6765        let claimed = |args: &[String]| {
6766            let mut words = Vec::new();
6767            if plugins::requested(args) {
6768                words.push("plugins");
6769            }
6770            if doctor::requested(args) {
6771                words.push("doctor");
6772            }
6773            #[cfg(feature = "art")]
6774            if micro::requested(args) {
6775                words.push("micro");
6776            }
6777            #[cfg(feature = "record")]
6778            if record::requested(args) {
6779                words.push("record");
6780            }
6781            #[cfg(feature = "serve")]
6782            if serve::requested(args) {
6783                words.push("serve");
6784            }
6785            #[cfg(feature = "interact")]
6786            words.extend(interactive::requested(args));
6787            if tools::bench_dispatch(args).unwrap_or(true) {
6788                words.push("bench");
6789            }
6790            let roots = ConfigRoots {
6791                home: None,
6792                cwd: std::env::temp_dir(),
6793                no_color_env: false,
6794            };
6795            if !matches!(config::inspect(args, &roots), Ok(None)) {
6796                words.push("config");
6797            }
6798            words
6799        };
6800        let words = [
6801            "plugins", "doctor", "record", "serve", "choose", "filter", "input", "confirm",
6802            "pager", "bench", "config",
6803        ];
6804        for word in words {
6805            // Without a mode flag the word is the subcommand (where built).
6806            let plain = claimed(&[word.to_string(), "--no-color".into()]);
6807            assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
6808            for flag in MODE_FLAGS {
6809                let args = [flag.to_string(), word.to_string()];
6810                assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
6811                let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
6812                assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
6813            }
6814        }
6815        assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
6816        assert!(doctor::requested(&["doctor".into()]));
6817    }
6818
6819    #[cfg(feature = "art")]
6820    #[test]
6821    fn diff_threshold_uses_the_reports_tie_rounding() {
6822        // The report prints 0.2 against 0.2, then 0.8 against 0.7.
6823        assert!(!diff_threshold_exceeded(0.25, 0.24));
6824        assert!(diff_threshold_exceeded(0.75, 0.74));
6825        assert!(!diff_threshold_exceeded(0.75, 0.76));
6826    }
6827
6828    #[test]
6829    fn detects_urls() {
6830        assert!(is_url("http://example.com"));
6831        assert!(is_url("https://example.com/x"));
6832        // RFC 3986: the scheme is case-insensitive.
6833        assert!(is_url("HTTPS://example.com"));
6834        assert!(is_url("Http://example.com"));
6835        assert!(!is_url("example.com"));
6836        assert!(!is_url("./file.md"));
6837        assert!(!is_url("-"));
6838        assert!(!is_url("")); // must not panic on a short/empty resource
6839        assert!(!is_url("ht"));
6840        assert!(!is_url("ftp://example.com"));
6841    }
6842
6843    #[test]
6844    fn uninformative_extensions_defer_to_content_type() {
6845        assert!(is_uninformative_ext("txt"));
6846        assert!(is_uninformative_ext("log"));
6847        assert!(!is_uninformative_ext("py"));
6848        assert!(!is_uninformative_ext("rs"));
6849    }
6850
6851    #[test]
6852    fn resource_ext_uses_basename_without_query() {
6853        assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
6854        assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
6855        assert_eq!(
6856            resource_ext("https://x.com/c.py?raw=1").as_deref(),
6857            Some("py")
6858        );
6859        // Dots in the host must not be mistaken for an extension.
6860        assert_eq!(resource_ext("https://api.example.com/data"), None);
6861        assert_eq!(resource_ext("https://x.com/"), None);
6862        assert_eq!(resource_ext("noext"), None);
6863    }
6864
6865    #[test]
6866    fn content_type_maps_mode_and_lexer() {
6867        assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
6868        assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
6869        assert_eq!(
6870            content_type_mode("application/json; charset=utf-8"),
6871            Some(Mode::Json)
6872        );
6873        assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
6874        assert_eq!(content_type_mode("text/html"), None); // -> syntax, not a mode
6875        assert_eq!(content_type_lexer("text/html"), Some("html"));
6876        assert_eq!(content_type_lexer("text/x-python"), Some("python"));
6877        assert_eq!(content_type_lexer("text/plain"), None);
6878    }
6879
6880    #[test]
6881    fn join_source_handles_string_and_array() {
6882        use serde_json::json;
6883        assert_eq!(join_source(&json!("a\nb")), "a\nb");
6884        // Array elements already carry their trailing newlines; they concat.
6885        assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
6886        assert_eq!(join_source(&json!(null)), "");
6887    }
6888
6889    #[test]
6890    fn ipynb_extension_auto_detects() {
6891        assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
6892    }
6893
6894    #[test]
6895    fn io_label_builds_execution_count() {
6896        // "In [1]:" — the plain text (styles aside) should read back exactly.
6897        assert_eq!(
6898            io_label("In ", Some(1), "green", "#66ff00").plain(),
6899            "In [1]:"
6900        );
6901        assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
6902    }
6903
6904    #[test]
6905    fn parse_padding_unpacks_like_upstream() {
6906        assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
6907        assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
6908        assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
6909        assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
6910        assert!(parse_padding("1,2,3").is_err()); // 3 values not allowed
6911        assert!(parse_padding("x").is_err());
6912    }
6913
6914    #[test]
6915    fn parse_box_maps_names() {
6916        assert!(matches!(parse_box("rounded"), Ok(Some(_))));
6917        assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
6918        // `none` is upstream's default and means NO panel, not `box.NONE`.
6919        assert!(matches!(parse_box("none"), Ok(None)));
6920        assert!(parse_box("bogus").is_err());
6921    }
6922
6923    #[test]
6924    fn paging_policy_respects_viewport_and_redirection() {
6925        assert!(!should_page(true, true, false, 100, 24));
6926        assert!(!should_page(false, true, true, 23, 24));
6927        assert!(should_page(false, true, true, 24, 24));
6928        assert!(should_page(true, false, true, 1, 24));
6929        assert!(!should_page(false, false, true, 100, 24));
6930    }
6931
6932    #[test]
6933    fn worker_arguments_preserve_resources_named_like_commands() {
6934        for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
6935            let args: Vec<String> = source.into_iter().map(str::to_string).collect();
6936            assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
6937        }
6938    }
6939
6940    #[test]
6941    fn worker_arguments_keep_option_values_and_remove_batch_operands() {
6942        let args: Vec<String> = [
6943            "--batch",
6944            "--title",
6945            "--batch",
6946            "--jobs",
6947            "2",
6948            "json",
6949            "--export-html",
6950            "out.html",
6951            "--",
6952            "--pager",
6953        ]
6954        .into_iter()
6955        .map(str::to_string)
6956        .collect();
6957        assert_eq!(
6958            worker_args(&args),
6959            ["--no-config", "--title", "--batch", "--json"]
6960        );
6961    }
6962
6963    /// Batch workers render with the parent's code highlighter and theme.
6964    #[test]
6965    fn worker_arguments_keep_the_code_highlighter() {
6966        let args: Vec<String> = [
6967            "--batch",
6968            "--highlighter",
6969            "syntect",
6970            "--code-theme",
6971            "ansi_dark",
6972            "a.py",
6973        ]
6974        .into_iter()
6975        .map(str::to_string)
6976        .collect();
6977        assert_eq!(
6978            worker_args(&args),
6979            [
6980                "--no-config",
6981                "--highlighter",
6982                "syntect",
6983                "--code-theme",
6984                "ansi_dark"
6985            ]
6986        );
6987    }
6988
6989    #[test]
6990    fn parses_pager_flag() {
6991        let s = |v: &str| v.to_string();
6992        assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
6993        assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
6994    }
6995
6996    #[test]
6997    fn parses_watch_options_and_rejects_orphans() {
6998        let s = |v: &str| v.to_string();
6999        let cli = parse(&[
7000            s("--watch"),
7001            s("--watch-interval"),
7002            s("0.25"),
7003            s("--watch-cache"),
7004            s("file.md"),
7005        ])
7006        .unwrap()
7007        .unwrap();
7008        assert!(cli.watch);
7009        assert_eq!(cli.watch_interval, 0.25);
7010        assert!(cli.watch_cache);
7011        assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7012        assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7013    }
7014
7015    #[test]
7016    fn parses_multi_file_watch_options() {
7017        let s = |v: &str| v.to_string();
7018        let parse = parse_inner;
7019        let cli = parse(&[
7020            s("--watch"),
7021            s("--watch-debounce"),
7022            s("0.3"),
7023            s("--watch-poll"),
7024            s("--watch-exit-on-error"),
7025            s("a.md"),
7026            s("b.json"),
7027        ])
7028        .unwrap()
7029        .unwrap();
7030        assert_eq!(cli.resources, ["a.md", "b.json"]);
7031        assert_eq!(cli.watch_debounce, 0.3);
7032        assert!(cli.watch_poll && cli.watch_exit_on_error);
7033        let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7034        assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7035        assert!(!default.watch_poll && !default.watch_exit_on_error);
7036        for orphan in [
7037            vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7038            vec![s("--watch-poll"), s("a.md")],
7039            vec![s("--watch-exit-on-error"), s("a.md")],
7040        ] {
7041            assert!(parse(&orphan)
7042                .err()
7043                .unwrap()
7044                .contains("only has an effect with --watch"));
7045        }
7046        for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7047            assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7048        }
7049        // Several resources remain an error without --watch (or --gif/--batch).
7050        assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7051    }
7052
7053    #[test]
7054    fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7055        let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7056        let _ = std::fs::remove_file(&path);
7057        let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7058        std::fs::write(&path, "first").unwrap();
7059        let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7060        std::fs::write(&path, "second content").unwrap();
7061        let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7062        let _ = std::fs::remove_file(&path);
7063        assert_eq!(missing, "missing");
7064        assert_ne!(first, missing);
7065        assert_ne!(first, second);
7066    }
7067
7068    #[test]
7069    fn parses_batch_controls_and_cli_values_override_config_defaults() {
7070        let s = |v: &str| v.to_string();
7071        let cli = parse(&[
7072            s("--no-config"),
7073            s("--batch"),
7074            s("--jobs"),
7075            s("3"),
7076            s("-o"),
7077            s("out.html"),
7078            s("--continue-on-error"),
7079            s("--collision"),
7080            s("suffix"),
7081            s("input"),
7082        ])
7083        .unwrap()
7084        .unwrap();
7085        assert!(cli.batch);
7086        assert_eq!(cli.jobs, 3);
7087        assert!(cli.continue_on_error);
7088        assert_eq!(cli.collision, CollisionPolicy::Suffix);
7089    }
7090
7091    #[test]
7092    fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7093        let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7094        let _ = std::fs::remove_dir_all(&root);
7095        let nested = root.join("nested");
7096        std::fs::create_dir_all(&nested).unwrap();
7097        std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7098        std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7099
7100        let root_arg = root.to_string_lossy().into_owned();
7101        let glob_arg = root
7102            .join("*richbatchonly.md")
7103            .to_string_lossy()
7104            .into_owned();
7105        let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7106        assert_eq!(inputs.len(), 2);
7107        assert!(inputs[0].ends_with("richbatchnested.md"));
7108        assert!(inputs[1].ends_with("richbatchonly.md"));
7109
7110        std::fs::remove_dir_all(root).unwrap();
7111    }
7112
7113    #[test]
7114    fn batch_output_suffixes_are_deterministic_for_collisions() {
7115        let inputs = ["one.md", "two.md"];
7116        let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7117        let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7118        assert_eq!(first, "one.html");
7119        assert_eq!(second, "two.html");
7120        assert_eq!(
7121            Path::new("one.html")
7122                .with_file_name("one-2.html")
7123                .to_string_lossy(),
7124            "one-2.html"
7125        );
7126    }
7127
7128    #[test]
7129    fn config_profiles_support_profiles_section_and_source_errors() {
7130        let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7131        std::fs::create_dir_all(&root).unwrap();
7132        let config = root.join("config.toml");
7133        std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7134        let args = vec![
7135            "--config".to_string(),
7136            config.to_string_lossy().into_owned(),
7137            "--profile".to_string(),
7138            "ci".to_string(),
7139        ];
7140        let merged = config_args(
7141            &args,
7142            &ConfigRoots {
7143                home: None,
7144                cwd: root.clone(),
7145                no_color_env: false,
7146            },
7147        )
7148        .unwrap();
7149        assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7150        assert!(merged
7151            .windows(2)
7152            .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7153
7154        let bad = root.join("bad.toml");
7155        std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7156        let error = config_args(
7157            &["--config".to_string(), bad.to_string_lossy().into_owned()],
7158            &ConfigRoots {
7159                home: None,
7160                cwd: root.clone(),
7161                no_color_env: false,
7162            },
7163        )
7164        .unwrap_err();
7165        assert!(error.contains("config "));
7166        assert!(error.contains("unknown key"));
7167        std::fs::remove_dir_all(root).unwrap();
7168    }
7169
7170    #[test]
7171    fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7172        let s = |v: &str| v.to_string();
7173        let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7174            .err()
7175            .unwrap();
7176        assert!(error.contains("--batch cannot be combined with --diff"));
7177        let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7178            .err()
7179            .unwrap();
7180        assert!(error.contains("--batch cannot be combined with --gif"));
7181        // One pager per item would open N interactive pagers over one plan.
7182        let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7183            .err()
7184            .unwrap();
7185        assert!(error.contains("--pager cannot be combined with --batch"));
7186    }
7187
7188    #[test]
7189    fn line_counts_follow_python_splitlines() {
7190        assert_eq!(python_line_count(""), 0);
7191        assert_eq!(python_line_count("a"), 1);
7192        assert_eq!(python_line_count("a\n"), 1);
7193        assert_eq!(python_line_count("a\n\n"), 2);
7194        assert_eq!(python_line_count("a\r\nb"), 2);
7195        assert_eq!(
7196            python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7197            8
7198        );
7199    }
7200
7201    #[test]
7202    fn glob_matches_metacharacters_rather_than_substrings() {
7203        assert!(glob_match("*.md", "notes.md"));
7204        // `.md` appears in the name, but the pattern anchors at the end.
7205        assert!(!glob_match("*.md", "notes.md.bak"));
7206        assert!(glob_match("a?c.md", "abc.md"));
7207        assert!(!glob_match("a?c.md", "ac.md"));
7208        assert!(glob_match("*", "anything"));
7209        assert!(glob_match("a*b*c", "axxbyyc"));
7210        assert!(!glob_match("a*b*c", "axxbyy"));
7211    }
7212
7213    #[test]
7214    fn batch_expansion_skips_symlinked_directories() {
7215        let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7216        let _ = std::fs::remove_dir_all(&root);
7217        std::fs::create_dir_all(&root).unwrap();
7218        std::fs::write(root.join("a.md"), "# a").unwrap();
7219        // A link back to the containing directory used to be walked forever.
7220        #[cfg(unix)]
7221        let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7222        #[cfg(windows)]
7223        let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7224        let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7225        if linked {
7226            assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7227        }
7228        assert!(inputs[0].ends_with("a.md"));
7229        let _ = std::fs::remove_dir_all(&root);
7230    }
7231
7232    #[test]
7233    fn subcommand_mode_wins_over_config_mode_default() {
7234        let s = |v: &str| v.to_string();
7235        assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7236        assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7237        // A value that happens to look like a subcommand is not a subcommand.
7238        assert!(!selects_mode_explicitly(&[
7239            s("--title"),
7240            s("json"),
7241            s("file")
7242        ]));
7243        assert!(!selects_mode_explicitly(&[s("file.md")]));
7244
7245        let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7246        let _ = std::fs::remove_dir_all(&root);
7247        std::fs::create_dir_all(&root).unwrap();
7248        let config = root.join("config.toml");
7249        std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7250        let merged = config_args(
7251            &[
7252                "--config".to_string(),
7253                config.to_string_lossy().into_owned(),
7254                "json".to_string(),
7255                "file.json".to_string(),
7256            ],
7257            &ConfigRoots {
7258                home: None,
7259                cwd: root.clone(),
7260                no_color_env: false,
7261            },
7262        )
7263        .unwrap();
7264        assert!(
7265            !merged.iter().any(|arg| arg == "--markdown"),
7266            "config mode must not override an explicit subcommand: {merged:?}"
7267        );
7268        let _ = std::fs::remove_dir_all(&root);
7269    }
7270
7271    #[test]
7272    fn suffix_collisions_step_past_files_already_on_disk() {
7273        let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7274        let _ = std::fs::remove_dir_all(&root);
7275        std::fs::create_dir_all(&root).unwrap();
7276        let target = root.join("out.html");
7277        std::fs::write(&target, "existing").unwrap();
7278        let target = target.to_string_lossy().into_owned();
7279
7280        let cli = parse(&[
7281            "--no-config".to_string(),
7282            "--batch".to_string(),
7283            "--collision".to_string(),
7284            "suffix".to_string(),
7285            "x".to_string(),
7286        ])
7287        .unwrap()
7288        .unwrap();
7289        let mut taken = std::collections::BTreeSet::new();
7290        let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7291        assert!(
7292            first.ends_with("out-2.html"),
7293            "existing file must be kept: {first}"
7294        );
7295        let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7296        assert!(second.ends_with("out-3.html"));
7297
7298        // `error` refuses rather than clobbering, and says how to proceed.
7299        let mut cli_error = cli.clone();
7300        cli_error.collision = CollisionPolicy::Error;
7301        let mut taken = std::collections::BTreeSet::new();
7302        let (class, message) =
7303            resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7304        assert_eq!(class, ExitClass::Input);
7305        assert!(message.contains("--overwrite"));
7306
7307        // `--overwrite` opts in explicitly.
7308        let mut cli_overwrite = cli.clone();
7309        cli_overwrite.overwrite = true;
7310        let mut taken = std::collections::BTreeSet::new();
7311        assert_eq!(
7312            resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7313            target
7314        );
7315        let _ = std::fs::remove_dir_all(&root);
7316    }
7317
7318    #[test]
7319    fn batch_outputs_keep_their_export_kind() {
7320        // The planned path need not end in `.html`; the kind is carried, not sniffed.
7321        let outputs = BatchOutputs {
7322            html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7323            ..BatchOutputs::default()
7324        };
7325        assert_eq!(outputs.html.as_deref(), Some("result.out"));
7326        assert!(outputs.svg.is_none());
7327    }
7328
7329    #[test]
7330    fn parses_export_flags() {
7331        let s = |v: &str| v.to_string();
7332        // Both take a PATH, and the resource is a separate positional.
7333        let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7334            .unwrap()
7335            .unwrap();
7336        assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7337        assert!(cli.export_html.is_none());
7338        assert_eq!(cli.resource.as_deref(), Some("x"));
7339
7340        let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7341        assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7342
7343        // Both together is allowed — upstream writes both files.
7344        let cli = parse(&[
7345            s("--export-html"),
7346            s("a.html"),
7347            s("--export-svg"),
7348            s("b.svg"),
7349            s("x"),
7350        ])
7351        .unwrap()
7352        .unwrap();
7353        assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7354        assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7355
7356        // A missing PATH is an error rather than swallowing the next argument.
7357        assert!(parse(&[s("--export-html")]).is_err());
7358        // The two export formats are mutually exclusive.
7359    }
7360
7361    #[test]
7362    fn is_number_matches_pattern() {
7363        // Every case checked against Python's own
7364        // `re.compile(r"\-?[0-9]*?\.?[0-9]*?").fullmatch`. The loose ones are
7365        // not oversights: that pattern really does accept a lone `-`, a lone
7366        // `.` and the empty string, and really does reject a padded `" 12 "`.
7367        for ok in [
7368            "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7369        ] {
7370            assert!(is_number(ok), "{ok:?} should be numeric");
7371        }
7372        for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7373            assert!(!is_number(no), "{no:?} should not be numeric");
7374        }
7375    }
7376
7377    #[test]
7378    fn render_csv_matches_upstream() {
7379        // Byte-parity with the Table real rich-cli's render_csv builds for this
7380        // CSV (captured from rich 15.0.0): HEAVY_HEAD box, blue border, the
7381        // numeric Age column right-justified with bold-green body + header cells.
7382        let table = build_csv_table(
7383            "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7384            Some(','),
7385            None,
7386            None,
7387            (None, None),
7388            false,
7389        )
7390        .expect("the sniffer reads this one");
7391        let out = Console::builder()
7392            .force_terminal(true)
7393            .color_system(Some(ColorSystem::Truecolor))
7394            .width(40)
7395            .no_color(false)
7396            .build()
7397            .render_to_string(&table);
7398        let expected = concat!(
7399            "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7400            "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7401            "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7402            "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7403            "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7404            "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7405            "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC  \x1b[34m│\x1b[0m\n",
7406            "\x1b[34m│\x1b[0m Bob   \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7407            "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA   \x1b[34m│\x1b[0m\n",
7408            "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7409        );
7410        assert_eq!(out, expected);
7411    }
7412}