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