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