Skip to main content

rich_cli/
lib.rs

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