Skip to main content

rich_cli/
lib.rs

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