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