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