Skip to main content

rich_cli/
lib.rs

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