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