1#![warn(clippy::return_self_not_must_use)]
36
37use std::io::{BufRead, IsTerminal, Read, Write};
38use std::path::{Path, PathBuf};
39use std::process::ExitCode;
40
41mod animation;
42#[cfg(feature = "interact")]
43mod approve_jump;
44mod authoring;
45#[cfg(feature = "art")]
46mod banner;
47mod batch;
48#[cfg_attr(target_os = "wasi", path = "batch_output_wasi.rs")]
49mod batch_output;
50mod batch_paths;
51#[cfg(test)]
52mod batch_races;
53mod chart;
54mod cli_spec;
55mod config;
56mod controls;
57mod demo;
58mod doctor;
59mod documents;
60mod host;
61mod inspect;
62#[cfg(feature = "interact")]
63mod interactive;
64mod logs;
65mod marketplace;
66#[cfg(feature = "art")]
67mod micro;
68mod plugins;
69mod profile;
70#[cfg(feature = "record")]
71mod record;
72mod redact_preview;
73mod render_target;
74#[cfg(feature = "serve")]
75mod serve;
76mod shell;
77mod sources;
78mod spin;
79mod structured_log;
80mod style_rules;
81mod tools;
82mod viewers;
83mod watch;
84use batch::run_batch;
85use config::{config_args, ConfigRoots};
86use marketplace::spec as marketplace_spec;
87
88use rich::cells::cell_len;
89use rich::markdown::Markdown;
90use rich::measure::Measurement;
91use rich::protocol::{LineRenderable, OwnedTableRows};
92use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
93use rich::text::Text;
94use rich::{
95 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
96 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
97 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
98 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
99 DEFAULT_TERMINAL_THEME,
100};
101use rich_data::csv::{has_header, read_rows, sniff, Dialect};
102use rich_data::infer::{InferredType, Inferrer};
103use rich_ext::cli::CliExtensions;
104use rich_ext::encoding::{has_utf16_bom, Encoding};
105use rich_ext::sanitize_terminal_controls;
106use rich_ext::ConsoleExt;
107
108fn text(content: &str) -> Box<dyn Renderable> {
110 Box::new(Text::new(content))
111}
112
113const VERSION: &str = env!("CARGO_PKG_VERSION");
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum Mode {
118 Auto,
120 Print,
122 Markdown,
124 Json,
126 Syntax,
128 Csv,
130 Ipynb,
132 Rst,
134 Org,
136 AsciiDoc,
138 Man,
141 Gif,
143 Diff,
145 Image,
147 Rule,
149 JsonLines,
151 Log,
153 Inspect,
155 AnsiExplain,
157 View,
160 Hex,
162 Unicode,
164 Env,
166 Capture,
168 Mermaid,
170 Dot,
172 Deps,
174 Schema,
177 Chart,
179 Profile,
181}
182
183impl Mode {
184 fn streams_records(self) -> bool {
185 matches!(self, Self::JsonLines | Self::Log)
186 }
187
188 fn draws_directly(self) -> bool {
189 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
190 }
191
192 fn allows_extensions(self) -> bool {
193 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
194 && !self.streams_records()
195 }
196
197 fn accepts_multiple_resources(self) -> bool {
198 matches!(
199 self,
200 Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
201 )
202 }
203}
204
205struct ModeSpec {
206 mode: Mode,
207 primary: &'static str,
208 aliases: &'static [&'static str],
209}
210
211const MODE_SPECS: &[ModeSpec] = &[
212 ModeSpec {
213 mode: Mode::Auto,
214 primary: "auto",
215 aliases: &[],
216 },
217 ModeSpec {
218 mode: Mode::Print,
219 primary: "print",
220 aliases: &["print"],
221 },
222 ModeSpec {
223 mode: Mode::Markdown,
224 primary: "markdown",
225 aliases: &["markdown", "md"],
226 },
227 ModeSpec {
228 mode: Mode::Json,
229 primary: "json",
230 aliases: &["json"],
231 },
232 ModeSpec {
233 mode: Mode::Syntax,
234 primary: "syntax",
235 aliases: &["syntax", "code"],
236 },
237 ModeSpec {
238 mode: Mode::Csv,
239 primary: "csv",
240 aliases: &["csv", "tsv"],
241 },
242 ModeSpec {
243 mode: Mode::Ipynb,
244 primary: "ipynb",
245 aliases: &["ipynb", "notebook"],
246 },
247 ModeSpec {
248 mode: Mode::Rst,
249 primary: "rst",
250 aliases: &["rst"],
251 },
252 ModeSpec {
253 mode: Mode::Org,
254 primary: "org",
255 aliases: &["org"],
256 },
257 ModeSpec {
258 mode: Mode::AsciiDoc,
259 primary: "asciidoc",
260 aliases: &["asciidoc", "adoc"],
261 },
262 ModeSpec {
263 mode: Mode::Man,
264 primary: "man",
265 aliases: &["man", "roff"],
266 },
267 ModeSpec {
268 mode: Mode::Gif,
269 primary: "gif",
270 aliases: &["gif"],
271 },
272 ModeSpec {
273 mode: Mode::Diff,
274 primary: "diff",
275 aliases: &["diff"],
276 },
277 ModeSpec {
278 mode: Mode::Image,
279 primary: "image",
280 aliases: &["image"],
281 },
282 ModeSpec {
283 mode: Mode::Rule,
284 primary: "rule",
285 aliases: &["rule"],
286 },
287 ModeSpec {
288 mode: Mode::JsonLines,
289 primary: "jsonl",
290 aliases: &["jsonl", "ndjson"],
291 },
292 ModeSpec {
293 mode: Mode::Log,
294 primary: "log",
295 aliases: &["log", "logs"],
296 },
297 ModeSpec {
298 mode: Mode::Inspect,
299 primary: "inspect",
300 aliases: &["inspect"],
301 },
302 ModeSpec {
303 mode: Mode::AnsiExplain,
304 primary: "ansi",
305 aliases: &["ansi", "ansi-explain"],
306 },
307 ModeSpec {
308 mode: Mode::View,
309 primary: "view",
310 aliases: &["view"],
311 },
312 ModeSpec {
313 mode: Mode::Hex,
314 primary: "hex",
315 aliases: &["hex", "hexdump"],
316 },
317 ModeSpec {
318 mode: Mode::Unicode,
319 primary: "unicode",
320 aliases: &["unicode"],
321 },
322 ModeSpec {
323 mode: Mode::Env,
324 primary: "env",
325 aliases: &["env"],
326 },
327 ModeSpec {
328 mode: Mode::Capture,
329 primary: "capture",
330 aliases: &["capture"],
331 },
332 ModeSpec {
333 mode: Mode::Mermaid,
334 primary: "mermaid",
335 aliases: &["mermaid", "mmd"],
336 },
337 ModeSpec {
338 mode: Mode::Dot,
339 primary: "dot",
340 aliases: &["dot", "graphviz"],
341 },
342 ModeSpec {
343 mode: Mode::Deps,
344 primary: "deps",
345 aliases: &["deps"],
346 },
347 ModeSpec {
348 mode: Mode::Schema,
349 primary: "schema",
350 aliases: &["schema"],
351 },
352 ModeSpec {
353 mode: Mode::Chart,
354 primary: "chart",
355 aliases: &["chart"],
356 },
357 ModeSpec {
358 mode: Mode::Profile,
359 primary: "profile",
360 aliases: &["profile"],
361 },
362];
363
364const RENDER_MODE_FLAGS: &str =
365 "--print/--markdown/--rst/--org/--asciidoc/--man/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368enum ReportFormat {
369 Human,
370 Json,
371}
372
373impl ReportFormat {
374 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
375 match option {
376 "--report" => {
377 *self = match value.ok_or("--report requires one of: human, json")? {
378 "human" => Self::Human,
379 "json" => Self::Json,
380 other => return Err(format!("unknown report format {other:?} (human, json)")),
381 };
382 }
383 "--machine-json" => *self = Self::Json,
384 _ => {}
385 }
386 Ok(())
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391enum ExitClass {
392 Success,
393 Usage,
394 Input,
395 Data,
396 #[cfg_attr(not(feature = "art"), allow(dead_code))]
397 Gate,
398}
399
400impl ExitClass {
401 fn code(self) -> u8 {
402 match self {
403 Self::Success => 0,
404 Self::Usage => 2,
405 Self::Input => 3,
406 Self::Data => 4,
407 Self::Gate => 5,
408 }
409 }
410
411 fn name(self) -> &'static str {
412 match self {
413 Self::Success => "success",
414 Self::Usage => "usage",
415 Self::Input => "input",
416 Self::Data => "data",
417 Self::Gate => "gate",
418 }
419 }
420
421 fn exit_code(self) -> ExitCode {
422 ExitCode::from(self.code())
423 }
424}
425
426#[derive(Clone, Copy, PartialEq, Eq, Debug)]
433enum ImageMode {
434 Auto,
436 Sixel,
438 Blocks,
440 Braille,
442 Quadrants,
444 Ascii,
446 None,
448}
449
450impl std::str::FromStr for ImageMode {
451 type Err = String;
452
453 fn from_str(value: &str) -> Result<Self, Self::Err> {
454 match value.to_ascii_lowercase().as_str() {
455 "auto" => Ok(Self::Auto),
456 "sixel" => Ok(Self::Sixel),
457 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
458 "braille" => Ok(Self::Braille),
459 "quadrants" | "quadrant" => Ok(Self::Quadrants),
460 "ascii" | "art" => Ok(Self::Ascii),
461 "none" | "off" => Ok(Self::None),
462 other => Err(format!(
463 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
464 )),
465 }
466 }
467}
468
469#[derive(Clone, Debug, Default, PartialEq)]
473struct SourceOptions {
474 head: Option<usize>,
476 tail: Option<usize>,
477 line_numbers: bool,
479 guides: bool,
481 lexer: Option<String>,
483 no_wrap: bool,
485 infer: bool,
489}
490
491impl SourceOptions {
492 fn wants_syntax(&self) -> bool {
494 self.head.is_some()
495 || self.tail.is_some()
496 || self.line_numbers
497 || self.guides
498 || self.lexer.is_some()
499 || self.no_wrap
500 }
501
502 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
507 let lines = python_line_count(code) as i64;
508 match (self.head, self.tail) {
509 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
510 (Some(head), None) => Ok(Some((1, head as i64))),
511 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
512 (None, None) => Ok(None),
513 }
514 }
515
516 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
518 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
519 .line_numbers(self.line_numbers)
520 .indent_guides(self.guides)
521 .word_wrap(!self.no_wrap);
522 if let Some((start, end)) = self.line_range(code)? {
523 syntax = syntax.line_range(Some(start), Some(end));
524 }
525 Ok(syntax)
526 }
527}
528
529fn python_line_count(text: &str) -> usize {
533 let mut count = 0;
534 let mut chars = text.chars().peekable();
535 let mut open = false;
536 while let Some(c) = chars.next() {
537 match c {
538 '\r' => {
539 if chars.peek() == Some(&'\n') {
540 chars.next();
541 }
542 count += 1;
543 open = false;
544 }
545 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
546 | '\u{2029}' => {
547 count += 1;
548 open = false;
549 }
550 _ => open = true,
551 }
552 }
553 count + usize::from(open)
554}
555
556#[derive(Clone)]
558struct Cli {
559 mode: Mode,
560 resource: Option<String>,
561 #[cfg_attr(not(feature = "art"), allow(dead_code))]
565 resources: Vec<String>,
566 #[cfg_attr(not(feature = "art"), allow(dead_code))]
568 loops: Option<usize>,
569 #[cfg_attr(not(feature = "art"), allow(dead_code))]
571 animation: animation::AnimationControls,
572 #[cfg_attr(not(feature = "art"), allow(dead_code))]
575 diff_threshold: Option<f32>,
576 #[cfg_attr(not(feature = "art"), allow(dead_code))]
578 image_mode: ImageMode,
579 #[cfg_attr(not(feature = "art"), allow(dead_code))]
580 image_fit: Option<String>,
581 #[cfg_attr(not(feature = "art"), allow(dead_code))]
582 image_anchor: Option<String>,
583 #[cfg_attr(not(feature = "art"), allow(dead_code))]
584 image_background: Option<ImageBackdrop>,
585 #[cfg_attr(not(feature = "art"), allow(dead_code))]
586 image_color: Option<String>,
587 #[cfg_attr(not(feature = "art"), allow(dead_code))]
588 image_dither: Option<String>,
589 #[cfg_attr(not(feature = "art"), allow(dead_code))]
590 image_color_distance: Option<String>,
591 #[cfg_attr(not(feature = "art"), allow(dead_code))]
592 image_rotate: Option<u16>,
593 #[cfg_attr(not(feature = "art"), allow(dead_code))]
594 image_flip_horizontal: bool,
595 #[cfg_attr(not(feature = "art"), allow(dead_code))]
596 image_flip_vertical: bool,
597 #[cfg_attr(not(feature = "art"), allow(dead_code))]
598 image_grayscale: bool,
599 #[cfg_attr(not(feature = "art"), allow(dead_code))]
600 image_max_width: Option<usize>,
601 #[cfg_attr(not(feature = "art"), allow(dead_code))]
602 image_max_height: Option<usize>,
603 #[cfg_attr(not(feature = "art"), allow(dead_code))]
605 image_brightness: Option<f32>,
606 #[cfg_attr(not(feature = "art"), allow(dead_code))]
607 image_contrast: Option<f32>,
608 #[cfg_attr(not(feature = "art"), allow(dead_code))]
609 image_gamma: Option<f32>,
610 log_presentation: String,
611 mermaid_backend: Option<MermaidBackend>,
614 dot_backend: Option<sources::DotBackend>,
617 graph_sources: sources::GraphSourceOptions,
619 chart: chart::ChartOptions,
621 schema: sources::SchemaOptions,
623 profile: profile::ProfileOptions,
625 rules: style_rules::RuleOptions,
628 highlighter: Option<String>,
630 code_theme: Option<String>,
632 plugins: Vec<String>,
635 #[cfg_attr(not(feature = "art"), allow(dead_code))]
638 micro_project: bool,
639 theme_styles: std::collections::BTreeMap<String, Style>,
640 theme_file_styles: std::collections::BTreeMap<String, Style>,
643 #[cfg_attr(not(feature = "art"), allow(dead_code))]
646 height: Option<usize>,
647 extensions: CliExtensions,
648 width: Option<usize>,
649 justify: Option<Justify>,
650 text_justify: Option<Justify>,
654 emoji: bool,
657 soft: bool,
659 max_width: Option<usize>,
661 rule_style: Option<Style>,
663 rule_char: Option<String>,
664 force_terminal: bool,
666 source: SourceOptions,
668 no_color: bool,
669 export_html: Option<String>,
672 export_svg: Option<String>,
674 export_png: Option<String>,
677 panel: Option<BoxSet>,
681 padding: Option<(usize, usize, usize, usize)>,
683 title: Option<String>,
685 caption: Option<String>,
687 panel_style: Option<Style>,
689 style: Option<Style>,
692 expand: bool,
695 pager: bool,
697 auto_pager: bool,
698 hyperlinks: bool,
702 sanitize: bool,
704 report_format: ReportFormat,
707 watch: bool,
709 watch_interval: f64,
711 watch_cache: bool,
713 watch_debounce: f64,
716 watch_poll: bool,
718 watch_exit_on_error: bool,
720 batch: bool,
721 progress: bool,
722 continue_on_error: bool,
723 jobs: usize,
724 dry_run: bool,
725 batch_paths: batch_paths::BatchPathOptions,
726 worker_args: Vec<String>,
727 overwrite: bool,
728 collision: CollisionPolicy,
729 data: inspect::DataOptions,
730 tools: tools::ToolOptions,
731 viewers: viewers::ViewerOptions,
732 logs: logs::LogOptions,
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
738enum MermaidBackend {
739 Text,
742 Mmdc,
745 Off,
747}
748
749impl MermaidBackend {
750 fn parse(value: &str) -> Result<Self, String> {
751 match value {
752 "text" => Ok(Self::Text),
753 "off" => Ok(Self::Off),
754 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
755 "mmdc" => Err(
756 "--mermaid-backend mmdc needs a build with the mmdc feature \
757 (cargo install rs-rich-cli --features mmdc)"
758 .into(),
759 ),
760 other => Err(format!(
761 "unknown Mermaid backend {other:?} (text, mmdc, off)"
762 )),
763 }
764 }
765}
766
767#[cfg(feature = "mermaid")]
769fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
770 rich_mermaid::MermaidOptions {
771 backend: match backend {
772 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
773 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
774 },
775 ..rich_mermaid::MermaidOptions::default()
776 }
777}
778
779fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
782 plugin_registry_with(mermaid, true)
783}
784
785fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
788 let mut registry = builtin_registry(mermaid, dot);
789 plugins::add_to(&mut registry);
790 registry
791}
792
793fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
796 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
797 if dot {
798 registry
799 .add_plugin(&rich_diagram::plugin::DotPlugin::default())
800 .expect("the DOT plugin registers cleanly");
801 }
802 #[cfg(feature = "mermaid")]
803 if mermaid != MermaidBackend::Off {
804 registry
805 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
806 .expect("the Mermaid plugin registers cleanly");
807 }
808 #[cfg(not(feature = "mermaid"))]
809 let _ = mermaid;
810 #[cfg(feature = "lumis")]
811 registry
812 .add_plugin(&rich_lumis::LumisPlugin)
813 .expect("the lumis plugin registers cleanly");
814 registry
815}
816
817fn code_highlighting(
821 highlighter: Option<&str>,
822 theme: Option<&str>,
823) -> Result<Option<rich::CodeHighlighting>, String> {
824 if highlighter.is_none() && theme.is_none() {
825 return Ok(None);
826 }
827 let mut registry = plugin_registry(MermaidBackend::Off);
828 let name = highlighter.unwrap_or("syntect");
829 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
830 return Err(match error {
831 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
832 if name == "lumis" && !cfg!(feature = "lumis") =>
833 {
834 format!(
835 "{error} (lumis needs a build with the lumis feature: cargo install \
836 rs-rich-cli --features lumis)"
837 )
838 }
839 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
840 let themes = registry
841 .code_highlighter(name)
842 .map(|engine| engine.themes())
843 .unwrap_or_default();
844 if themes.len() <= 40 {
845 format!("{error}; its themes: {}", themes.join(", "))
846 } else {
847 format!(
848 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
849 lists them all",
850 themes.len(),
851 themes[..12].join(", ")
852 )
853 }
854 }
855 other => other.to_string(),
856 });
857 }
858 Ok(registry.code_highlighting())
859}
860
861fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
863 use rich::ConsoleCodeHighlighting;
864 if let Ok(Some(highlighting)) =
865 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
866 {
867 console.set_code_highlighting(Some(highlighting));
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
872enum CollisionPolicy {
873 Error,
874 Overwrite,
875 Suffix,
876}
877
878fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
890 Markdown::new(source).hyperlinks(hyperlinks)
891}
892
893pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
908 dispatch(remember_raw_args(args))
909}
910
911static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
915 std::sync::Mutex::new(Vec::new());
916
917fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
926 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
927 let args = args
928 .into_iter()
929 .map(|arg| match arg.into_string() {
930 Ok(arg) => arg,
931 Err(arg) => {
932 let spelling = escaped_path(&arg);
933 match raw.iter_mut().find(|(known, _)| *known == spelling) {
934 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
935 Some(_) => {}
936 None => raw.push((spelling.clone(), Some(arg))),
937 }
938 spelling
939 }
940 })
941 .collect();
942 if let Ok(mut slot) = RAW_ARGS.lock() {
943 *slot = raw;
944 }
945 args
946}
947
948pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
952 RAW_ARGS
953 .lock()
954 .ok()
955 .and_then(|raw| {
956 raw.iter()
957 .find(|(spelling, _)| spelling == arg)
958 .and_then(|(_, original)| original.clone())
959 })
960 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
961}
962
963pub(crate) fn text_arg(arg: &str) -> String {
967 if !arg.starts_with('\0') {
968 return arg.to_string();
969 }
970 RAW_ARGS
971 .lock()
972 .ok()
973 .and_then(|raw| {
974 raw.iter()
975 .find(|(spelling, _)| spelling == arg)
976 .and_then(|(_, original)| original.clone())
977 })
978 .map_or_else(
979 || arg.to_string(),
980 |original| original.to_string_lossy().into_owned(),
981 )
982}
983
984pub(crate) fn path_arg(path: &Path) -> String {
992 if let Some(text) = path.to_str() {
993 return text.to_string();
994 }
995 let spelling = escaped_path(path.as_os_str());
996 if let Ok(mut raw) = RAW_ARGS.lock() {
997 let original = path.as_os_str().to_owned();
998 match raw.iter_mut().find(|(known, _)| *known == spelling) {
999 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
1000 Some(_) => {}
1001 None => raw.push((spelling.clone(), Some(original))),
1002 }
1003 }
1004 spelling
1005}
1006
1007#[cfg(unix)]
1010fn escaped_path(path: &std::ffi::OsStr) -> String {
1011 use std::os::unix::ffi::OsStrExt;
1012 let mut out = String::from('\0');
1013 for chunk in path.as_bytes().utf8_chunks() {
1014 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
1015 for byte in chunk.invalid() {
1016 out.push_str(&format!("\\x{byte:02X}"));
1017 }
1018 }
1019 out
1020}
1021
1022#[cfg(not(unix))]
1023fn escaped_path(path: &std::ffi::OsStr) -> String {
1024 format!("\0{}", path.to_string_lossy())
1025}
1026
1027pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
1037 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
1040 if let Ok(mut slot) = SELF_PROGRAM.lock() {
1041 *slot = program;
1042 }
1043 IN_HOST.store(true, std::sync::atomic::Ordering::SeqCst);
1044 let code = run(args);
1045 IN_HOST.store(false, std::sync::atomic::Ordering::SeqCst);
1046 code
1047}
1048
1049static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
1051
1052static IN_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1054
1055pub(crate) fn in_host() -> bool {
1058 IN_HOST.load(std::sync::atomic::Ordering::SeqCst)
1059}
1060
1061static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
1063
1064fn dispatch(args: Vec<String>) -> ExitCode {
1065 plugins::install(Vec::new());
1068 if demo::requested(&args) {
1069 return demo::dispatch(&args);
1070 }
1071 if doctor::requested(&args) {
1072 return doctor::dispatch(&args);
1073 }
1074 if plugins::requested(&args) {
1075 return plugins::dispatch(&args);
1076 }
1077 if marketplace::requested(&args) {
1078 return marketplace::dispatch(&args);
1079 }
1080 #[cfg(feature = "art")]
1081 if banner::requested(&args) {
1082 return banner::dispatch(&args);
1083 }
1084 if shell::requested(&args) {
1085 return shell::dispatch(&args);
1086 }
1087 if spin::requested(&args) {
1088 return spin::dispatch(&args);
1089 }
1090 #[cfg(feature = "art")]
1091 if micro::requested(&args) {
1092 return micro::dispatch(&args);
1093 }
1094 #[cfg(feature = "interact")]
1095 if let Some(command) = interactive::requested(&args) {
1096 return interactive::dispatch(command, &args);
1097 }
1098 #[cfg(feature = "interact")]
1099 if let Some(command) = approve_jump::requested(&args) {
1100 return approve_jump::dispatch(command, &args);
1101 }
1102 #[cfg(feature = "record")]
1103 if record::requested(&args) {
1104 return record::dispatch(&args);
1105 }
1106 #[cfg(feature = "serve")]
1107 if serve::requested(&args) {
1108 return serve::dispatch(&args);
1109 }
1110 match parse(&args) {
1111 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
1113 if let Err(message) = plugins::check_linked() {
1114 return fail(&cli, ExitClass::Usage, message);
1115 }
1116 if !cli.plugins.is_empty() || marketplace::has_installed() {
1118 match plugins::load(&cli.plugins) {
1119 Ok(loaded) => plugins::install(loaded),
1120 Err((class, message)) => return fail(&cli, class, message),
1121 }
1122 }
1123 if let Some(message) = cli
1125 .data
1126 .unknown_transform(&plugin_registry(MermaidBackend::Text))
1127 {
1128 return fail(&cli, ExitClass::Usage, message);
1129 }
1130 if cli.batch && cfg!(target_os = "wasi") {
1131 return fail(&cli, ExitClass::Usage, host::unavailable("--batch"));
1132 }
1133 if cli.batch {
1134 if let Err(error) = batch::install_interrupt_handler() {
1135 return fail(
1136 &cli,
1137 ExitClass::Input,
1138 format!("cannot install batch interruption handler: {error}"),
1139 );
1140 }
1141 }
1142 run_cli(cli)
1143 }
1144 Err(message) => emit_error(
1145 wants_json_report(&args),
1146 ExitClass::Usage,
1147 &format!("{message} (try --help)"),
1148 ),
1149 }
1150}
1151
1152fn self_command() -> std::io::Result<std::process::Command> {
1156 let program = SELF_PROGRAM
1157 .lock()
1158 .map(|program| program.clone())
1159 .unwrap_or_default();
1160 Ok(match program.split_first() {
1161 Some((executable, rest)) => {
1162 let mut command = std::process::Command::new(executable);
1163 command.args(rest);
1164 command
1165 }
1166 None => std::process::Command::new(std::env::current_exe()?),
1167 })
1168}
1169
1170const VALUE_OPTIONS: &[&str] = &[
1173 "--batch-input-root",
1174 "--batch-name-template",
1175 "--log-presentation",
1176 "--level",
1177 "--where",
1178 "--parser",
1179 "--time",
1180 "--output",
1181 "--mermaid-backend",
1182 "--dot-backend",
1183 "--metadata",
1184 "--why",
1185 "--package",
1186 "--timings",
1187 "--audit",
1188 "--build-profile",
1189 "--baseline",
1190 "--save",
1191 "--policy",
1192 "--sbom",
1193 "--depth",
1194 "--kind",
1195 "--x",
1196 "--y",
1197 "--sample",
1198 "--columns",
1199 "--top",
1200 "--max-paths",
1201 "--style-rule",
1202 "--rules",
1203 "--highlighter",
1204 "--code-theme",
1205 "--plugin",
1206 "-w",
1207 "--width",
1208 "-o",
1209 "--export-html",
1210 "--export-svg",
1211 "--export-png",
1212 "-a",
1213 "--panel",
1214 "-d",
1215 "--padding",
1216 "-h",
1217 "--head",
1218 "-t",
1219 "--tail",
1220 "--lexer",
1221 "-W",
1222 "--max-width",
1223 "--rule-style",
1224 "--rule-char",
1225 "--title",
1226 "--caption",
1227 "-s",
1228 "--style",
1229 "-S",
1230 "--panel-style",
1231 "--report",
1232 "--image-mode",
1233 "--image-fit",
1234 "--image-anchor",
1235 "--image-background",
1236 "--height",
1237 "--theme",
1238 "--theme-style",
1239 "--theme-file",
1240 "--image-color",
1241 "--image-dither",
1242 "--image-color-distance",
1243 "--image-rotate",
1244 "--image-max-width",
1245 "--image-max-height",
1246 "--image-brightness",
1247 "--image-contrast",
1248 "--image-gamma",
1249 "--demo-section",
1250 "--demo-delay",
1251 "--watch-interval",
1252 "--interval",
1253 "--watch-debounce",
1254 "--gif-mode",
1255 "--encoding",
1256 "--threshold",
1257 "--loop",
1258 "--speed",
1259 "--fps",
1260 "--max-frames",
1261 "--max-duration",
1262 "--jobs",
1263 "--collision",
1264 "--format",
1265 "--select",
1266 "--filter",
1267 "--highlight",
1268 "--transform",
1269 "--find",
1270 "--max-depth",
1271 "--max-length",
1272 "--compare",
1273 "--context",
1274 "--language",
1275 "--search",
1276 "--offset",
1277 "--length",
1278 "--bytes-per-line",
1279 "--group",
1280 "--limit",
1281 "--cast",
1282 "--redact-pattern",
1283 "--config",
1284 "--profile",
1285];
1286
1287fn selects_mode_explicitly(args: &[String]) -> bool {
1291 explicit_mode(args).is_some()
1292}
1293
1294fn explicit_mode(args: &[String]) -> Option<Mode> {
1296 let mut iter = args.iter();
1297 let mut seen_positional = false;
1298 while let Some(arg) = iter.next() {
1299 if arg == "--" {
1300 break;
1301 }
1302 if VALUE_OPTIONS.contains(&arg.as_str()) {
1303 iter.next();
1304 continue;
1305 }
1306 if arg.starts_with('-') && arg.len() > 1 {
1307 if let Some(flag) = mode_flag_alias(arg) {
1308 return command_mode(&flag[2..]);
1309 }
1310 continue;
1311 }
1312 if !seen_positional {
1313 seen_positional = true;
1314 if let Some(mode) = command_mode(arg) {
1315 return Some(mode);
1316 }
1317 }
1318 }
1319 None
1320}
1321
1322fn command_word(args: &[String]) -> Option<&str> {
1325 let mut iter = args.iter();
1326 while let Some(arg) = iter.next() {
1327 if arg == "--" {
1328 return None;
1329 }
1330 if VALUE_OPTIONS.contains(&arg.as_str()) {
1331 iter.next();
1332 } else if !arg.starts_with('-') || arg == "-" {
1333 return command_mode(arg).map(|_| arg.as_str());
1334 }
1335 }
1336 None
1337}
1338
1339pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1345 let mut iter = args.iter();
1346 while let Some(arg) = iter.next() {
1347 if arg == "--" || mode_flag_alias(arg).is_some() {
1348 return None;
1349 }
1350 if VALUE_OPTIONS.contains(&arg.as_str()) {
1351 iter.next();
1352 } else if !arg.starts_with('-') || arg == "-" {
1353 return Some(arg);
1354 }
1355 }
1356 None
1357}
1358
1359fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1361 Some(match arg {
1362 "--print" | "-p" => "--print",
1363 "--markdown" | "-m" => "--markdown",
1364 "--json" | "-j" | "-J" => "--json",
1365 "--syntax" | "-x" => "--syntax",
1366 "--csv" => "--csv",
1367 "--ipynb" => "--ipynb",
1368 "--rst" => "--rst",
1369 "--org" => "--org",
1370 "--asciidoc" | "--adoc" => "--asciidoc",
1371 "--man" | "--roff" => "--man",
1372 "--jsonl" | "--ndjson" => "--jsonl",
1373 "--log" => "--log",
1374 "--rule" | "-u" => "--rule",
1375 "--image" => "--image",
1376 "--gif" => "--gif",
1377 "--diff" => "--diff",
1378 "--inspect" => "--inspect",
1379 "--ansi-explain" => "--ansi-explain",
1380 _ => return None,
1381 })
1382}
1383
1384fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1385 let message = &controls::shown(message);
1388 if json {
1389 eprintln!(
1390 "{}",
1391 serde_json::json!({
1392 "ok": false,
1393 "code": class.name(),
1394 "exit_code": class.code(),
1395 "message": message,
1396 "error": {
1397 "message": message,
1398 },
1399 })
1400 );
1401 } else {
1402 eprintln!("rich: {message}");
1403 }
1404 class.exit_code()
1405}
1406
1407fn emit_success_report(format: ReportFormat) {
1408 if format == ReportFormat::Json {
1409 eprintln!(
1410 "{}",
1411 serde_json::json!({
1412 "ok": true,
1413 "code": ExitClass::Success.name(),
1414 "exit_code": ExitClass::Success.code(),
1415 "result": {},
1416 })
1417 );
1418 }
1419}
1420
1421fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1422 let message = controls::shown(message.as_ref());
1423 if watch::capture_error(&message) {
1425 return class.exit_code();
1426 }
1427 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1428}
1429
1430fn success(cli: &Cli) -> ExitCode {
1431 if !watch::capturing() {
1432 emit_success_report(cli.report_format);
1433 }
1434 ExitClass::Success.exit_code()
1435}
1436
1437#[cfg(test)]
1444fn glob_match(pattern: &str, text: &str) -> bool {
1445 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1446 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1447 glob_match_units(&pattern, &text)
1448}
1449
1450#[derive(Clone, Copy, PartialEq, Eq)]
1453enum GlobUnit {
1454 Char(char),
1455 #[cfg_attr(not(unix), allow(dead_code))]
1457 Byte(u8),
1458}
1459
1460fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1463 #[cfg(unix)]
1464 {
1465 use std::os::unix::ffi::OsStrExt;
1466 let mut units = Vec::new();
1467 for chunk in name.as_bytes().utf8_chunks() {
1468 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1469 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1470 }
1471 units
1472 }
1473 #[cfg(not(unix))]
1474 {
1475 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1476 }
1477}
1478
1479fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1480 const ANY: GlobUnit = GlobUnit::Char('?');
1481 const STAR: GlobUnit = GlobUnit::Char('*');
1482 let (mut p, mut t) = (0usize, 0usize);
1483 let (mut star, mut retry) = (None, 0usize);
1484 while t < text.len() {
1485 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1486 p += 1;
1487 t += 1;
1488 } else if p < pattern.len() && pattern[p] == STAR {
1489 star = Some(p);
1490 retry = t;
1491 p += 1;
1492 } else if let Some(open) = star {
1493 p = open + 1;
1494 retry += 1;
1495 t = retry;
1496 } else {
1497 return false;
1498 }
1499 }
1500 while p < pattern.len() && pattern[p] == STAR {
1501 p += 1;
1502 }
1503 p == pattern.len()
1504}
1505
1506fn has_glob_meta(value: &str) -> bool {
1507 value.contains('*') || value.contains('?')
1508}
1509
1510fn split_glob(resource: &str) -> (PathBuf, String) {
1512 let meta = resource.find(['*', '?']).unwrap_or(0);
1513 match resource[..meta].rfind(['/', '\\']) {
1514 Some(cut) => (
1515 PathBuf::from(&resource[..cut]),
1516 resource[cut + 1..].to_string(),
1517 ),
1518 None => (PathBuf::from("."), resource.to_string()),
1519 }
1520}
1521
1522fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1530 let mut stack = vec![dir.to_path_buf()];
1531 while let Some(current) = stack.pop() {
1532 batch::check_interrupted()?;
1533 let entries = std::fs::read_dir(¤t)
1534 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1535 for entry in entries {
1536 batch::check_interrupted()?;
1537 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1538 let path = entry.path();
1539 let kind = std::fs::symlink_metadata(&path)
1540 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1541 .file_type();
1542 if kind.is_symlink() {
1543 continue;
1544 }
1545 if kind.is_dir() {
1546 stack.push(path);
1547 } else if kind.is_file() {
1548 out.push(path_arg(&path));
1549 }
1550 }
1551 }
1552 Ok(())
1553}
1554
1555fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1556 let mut out = Vec::new();
1557 for resource in resources {
1558 batch::check_interrupted()?;
1559 if resource == "-" || is_url(resource) {
1560 return Err("batch resources must be local files or directories".into());
1561 }
1562 if has_glob_meta(resource) {
1563 let raw = fs_path(resource);
1567 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1568 let (dir, pattern) = if escaped {
1569 let parent = raw.parent().unwrap_or(Path::new(""));
1570 if has_glob_meta(&parent.to_string_lossy()) {
1571 return Err(format!(
1572 "batch glob {} may only use * and ? in the final path segment",
1573 controls::shown(resource)
1574 ));
1575 }
1576 let dir = if parent.as_os_str().is_empty() {
1577 PathBuf::from(".")
1578 } else {
1579 parent.to_path_buf()
1580 };
1581 (dir, glob_units(raw.file_name().unwrap_or_default()))
1582 } else {
1583 let (dir, pattern) = split_glob(resource);
1584 if pattern.contains('/') || pattern.contains('\\') {
1585 return Err(format!(
1586 "batch glob {resource:?} may only use * and ? in the final path segment"
1587 ));
1588 }
1589 (dir, pattern.chars().map(GlobUnit::Char).collect())
1590 };
1591 let entries = std::fs::read_dir(&dir)
1592 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1593 for entry in entries {
1594 batch::check_interrupted()?;
1595 let entry =
1596 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1597 let path = entry.path();
1598 if !path.is_file() {
1599 continue;
1600 }
1601 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1602 out.push(path_arg(&path));
1603 }
1604 }
1605 continue;
1606 }
1607 let path = fs_path(resource);
1608 if path.is_file() {
1609 out.push(resource.clone());
1610 } else if path.is_dir() {
1611 collect_files(&path, &mut out)?;
1612 } else {
1613 return Err(format!("batch resource does not exist: {resource}"));
1614 }
1615 }
1616 batch::check_interrupted()?;
1617 out.sort();
1618 out.dedup();
1619 batch::check_interrupted()?;
1620 if out.is_empty() {
1621 return Err("batch plan contains no files".into());
1622 }
1623 Ok(out)
1624}
1625
1626#[derive(Default, Clone)]
1632struct BatchOutputs {
1633 html: Option<String>,
1634 svg: Option<String>,
1635}
1636
1637fn batch_export_path(
1638 path: Option<&str>,
1639 input: &str,
1640 index: usize,
1641 total: usize,
1642) -> Option<String> {
1643 let path = path?;
1644 if total == 1 {
1645 return Some(path.into());
1646 }
1647 let p = fs_path(path);
1648 let input = fs_path(input);
1650 let stem = input
1651 .file_stem()
1652 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1653 let mut name = stem.to_owned();
1654 match p.extension() {
1655 Some(ext) if !ext.is_empty() => {
1656 name.push(".");
1657 name.push(ext);
1658 }
1659 _ => name.push(format!("-{index}")),
1660 }
1661 Some(path_arg(&if p.is_dir() {
1662 p.join(name)
1663 } else {
1664 p.with_file_name(name)
1665 }))
1666}
1667
1668fn suffixed_path(path: &Path, suffix: usize) -> String {
1670 let mut name = path
1671 .file_stem()
1672 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1673 .to_owned();
1674 name.push(format!("-{suffix}"));
1675 if let Some(ext) = path.extension() {
1676 name.push(".");
1677 name.push(ext);
1678 }
1679 path_arg(&path.with_file_name(name))
1680}
1681
1682fn resolve_destination(
1688 cli: &Cli,
1689 candidate: String,
1690 taken: &mut std::collections::BTreeSet<String>,
1691) -> Result<String, (ExitClass, String)> {
1692 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1693 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1694 };
1695 match cli.collision {
1696 CollisionPolicy::Overwrite => {
1697 taken.insert(candidate.clone());
1698 Ok(candidate)
1699 }
1700 CollisionPolicy::Suffix => {
1701 let mut chosen = candidate.clone();
1702 let mut suffix = 2;
1703 while occupied(&chosen, taken) {
1704 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1705 chosen = suffixed_path(&fs_path(&candidate), suffix);
1706 suffix += 1;
1707 }
1708 taken.insert(chosen.clone());
1709 Ok(chosen)
1710 }
1711 CollisionPolicy::Error => {
1712 if taken.contains(&candidate) {
1713 return Err((
1714 ExitClass::Usage,
1715 format!("batch output collision: {candidate}"),
1716 ));
1717 }
1718 if !cli.overwrite && fs_path(&candidate).exists() {
1719 return Err((
1720 ExitClass::Input,
1721 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1722 ));
1723 }
1724 taken.insert(candidate.clone());
1725 Ok(candidate)
1726 }
1727 }
1728}
1729
1730fn worker_args(args: &[String]) -> Vec<String> {
1733 let mut result = vec!["--no-config".into()];
1734 let mut iter = args.iter();
1735 let mut seen_resource = false;
1736 let mut selected_mode = false;
1737 while let Some(arg) = iter.next() {
1738 if arg == "--" {
1739 break;
1740 }
1741 if VALUE_OPTIONS.contains(&arg.as_str()) {
1742 let value = iter.next();
1743 if !matches!(
1744 arg.as_str(),
1745 "--jobs"
1746 | "--batch-input-root"
1747 | "--batch-name-template"
1748 | "--collision"
1749 | "--report"
1750 | "-o"
1751 | "--export-html"
1752 | "--export-svg"
1753 | "--config"
1754 | "--profile"
1755 ) {
1756 result.push(arg.clone());
1757 if let Some(value) = value {
1758 result.push(value.clone());
1759 }
1760 }
1761 } else if arg.starts_with('-') && arg != "-" {
1762 selected_mode |= mode_flag_alias(arg).is_some();
1763 if !matches!(
1764 arg.as_str(),
1765 "--batch"
1766 | "--batch-preserve-dirs"
1767 | "--no-batch-preserve-dirs"
1768 | "--no-batch"
1769 | "--dry-run"
1770 | "--progress"
1771 | "--no-progress"
1772 | "--continue-on-error"
1773 | "--no-continue-on-error"
1774 | "--overwrite"
1775 | "--no-overwrite"
1776 | "--machine-json"
1777 ) {
1778 result.push(arg.clone());
1779 }
1780 } else if !seen_resource {
1781 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1782 selected_mode = true;
1783 result.push(format!("--{}", mode_name(mode)));
1784 } else {
1785 seen_resource = true;
1786 }
1787 }
1788 }
1789 result
1790}
1791
1792fn wants_json_report(args: &[String]) -> bool {
1793 let mut format = ReportFormat::Human;
1794 let mut iter = args.iter();
1795 while let Some(arg) = iter.next() {
1796 match arg.as_str() {
1797 "--report" => {
1798 if let Some(value) = iter.next() {
1799 let _ = format.apply_option("--report", Some(value));
1800 }
1801 }
1802 "--machine-json" => {
1803 let _ = format.apply_option("--machine-json", None);
1804 }
1805 "--" => break,
1806 _ => {}
1807 }
1808 }
1809 format == ReportFormat::Json
1810}
1811
1812fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1824 let name = name.to_ascii_lowercase();
1825 if name == "none" {
1826 return Ok(None);
1827 }
1828 PANEL_BOXES
1829 .iter()
1830 .find(|(known, _)| *known == name)
1831 .map(|(_, set)| Some(*set))
1832 .ok_or_else(|| {
1833 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1834 format!(
1835 "unknown panel box {name:?} (use none or {})",
1836 names.join(", ")
1837 )
1838 })
1839}
1840
1841pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1843 use rich::r#box as b;
1844 &[
1845 ("ascii", b::ASCII),
1846 ("ascii2", b::ASCII2),
1847 ("square", b::SQUARE),
1848 ("rounded", b::ROUNDED),
1849 ("heavy", b::HEAVY),
1850 ("double", b::DOUBLE),
1851 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1852 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1853 ("minimal", b::MINIMAL),
1854 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1855 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1856 ("simple", b::SIMPLE),
1857 ("simple_head", b::SIMPLE_HEAD),
1858 ("simple_heavy", b::SIMPLE_HEAVY),
1859 ("horizontals", b::HORIZONTALS),
1860 ("heavy_edge", b::HEAVY_EDGE),
1861 ("heavy_head", b::HEAVY_HEAD),
1862 ("double_edge", b::DOUBLE_EDGE),
1863 ("markdown", b::MARKDOWN),
1864 ]
1865};
1866
1867fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1871 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1872 let parts: Result<Vec<usize>, _> = value
1873 .split(',')
1874 .map(|p| p.trim().parse::<usize>())
1875 .collect();
1876 let parts = parts.map_err(|_| error())?;
1877 match parts.as_slice() {
1878 [p] => Ok((*p, *p, *p, *p)),
1879 [v, h] => Ok((*v, *h, *v, *h)),
1880 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1881 _ => Err(error()),
1882 }
1883}
1884
1885fn mode_name(mode: Mode) -> &'static str {
1887 MODE_SPECS
1888 .iter()
1889 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1890 .unwrap_or("unknown")
1891}
1892
1893fn command_mode(command: &str) -> Option<Mode> {
1894 MODE_SPECS
1895 .iter()
1896 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1897}
1898
1899fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1901 let flag = match flag {
1902 "-h" | "--head" => "--head",
1903 _ => "--tail",
1904 };
1905 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1906 match value.parse::<i64>() {
1907 Ok(lines) if lines >= 1 => Ok(lines as usize),
1908 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1909 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1910 }
1911}
1912
1913fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1914 if *current != Mode::Auto && *current != mode {
1915 return Err(format!(
1916 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1917 ));
1918 }
1919 *current = mode;
1920 Ok(())
1921}
1922
1923fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1925 let roots = ConfigRoots::default();
1926 if authoring::dispatch(args)? {
1927 return Ok(None);
1928 }
1929 if tools::bench_dispatch(args)? {
1930 return Ok(None);
1931 }
1932 if let Some(output) = config::inspect(args, &roots)? {
1933 authoring::out(&format!("{output}\n"));
1934 return Ok(None);
1935 }
1936 let merged = config_args(args, &roots)?;
1937 parse_inner(&merged)
1938}
1939
1940fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1941 let mut mode = Mode::Auto;
1942 let mut resources: Vec<String> = Vec::new();
1943 let mut loops = None;
1944 let mut animation = animation::AnimationControls::default();
1945 let mut diff_threshold = None;
1946 let mut image_mode = ImageMode::Auto;
1947 let mut image_fit = None;
1948 let mut image_anchor = None;
1949 let mut image_background = None;
1950 let mut image_color = None;
1951 let mut image_dither = None;
1952 let mut image_color_distance = None;
1953 let mut image_rotate = None;
1954 let mut image_flip_horizontal = false;
1955 let mut image_flip_vertical = false;
1956 let mut image_grayscale = false;
1957 let mut image_max_width = None;
1958 let mut image_max_height = None;
1959 let mut image_brightness = None;
1960 let mut image_contrast = None;
1961 let mut image_gamma = None;
1962 let mut log_presentation = String::from("plain");
1963 let mut mermaid_backend = None;
1964 let mut dot_backend = None;
1965 let mut graph_sources = sources::GraphSourceOptions::default();
1966 let mut chart = chart::ChartOptions::default();
1967 let mut schema_options = sources::SchemaOptions::default();
1968 let mut profile_options = profile::ProfileOptions::default();
1969 let mut rule_options = style_rules::RuleOptions::default();
1970 let mut highlighter: Option<String> = None;
1971 let mut plugins: Vec<String> = Vec::new();
1972 let mut code_theme: Option<String> = None;
1973 let mut theme_styles = std::collections::BTreeMap::new();
1974 let mut theme_file_styles = std::collections::BTreeMap::new();
1975 let mut height = None;
1976 let mut extensions = CliExtensions::default();
1977 let mut data = inspect::DataOptions::default();
1978 let mut tool_options = tools::ToolOptions::default();
1979 let mut viewer_options = viewers::ViewerOptions::default();
1980 let mut log_options = logs::LogOptions::default();
1981 let mut paging_explicit = false;
1983 let mut ansi_word = false;
1985 let mut width = None;
1986 let mut justify = None;
1987 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1988 let mut export_html: Option<String> = None;
1989 let mut export_svg: Option<String> = None;
1990 let mut export_png: Option<String> = None;
1991 let mut panel = None;
1992 let mut padding = None;
1993 let mut title = None;
1994 let mut caption = None;
1995 let mut panel_style = None;
1996 let mut style = None;
1997 let mut expand = false;
1998 let mut pager = false;
1999 let mut auto_pager = false;
2000 let mut hyperlinks = false;
2001 let mut source = SourceOptions::default();
2002 let mut text_flags = [false; 4];
2005 let mut emoji = false;
2006 let mut soft = false;
2007 let mut max_width = None;
2008 let mut rule_style = None;
2009 let mut rule_char = None;
2010 let mut force_terminal = false;
2011 let mut sanitize = None;
2014 let mut report_format = ReportFormat::Human;
2015 let mut watch = false;
2016 let mut watch_interval = 1.0;
2017 let mut watch_cache = false;
2018 let mut watch_debounce: Option<f64> = None;
2019 let mut watch_poll = false;
2020 let mut watch_exit_on_error = false;
2021 let mut batch = false;
2022 let mut progress = true;
2023 let mut micro_project = false;
2024 let mut continue_on_error = false;
2025 let mut jobs = 1usize;
2026 let mut dry_run = false;
2027 let mut batch_paths = batch_paths::BatchPathOptions::default();
2028 let mut overwrite = false;
2029 let mut collision = CollisionPolicy::Error;
2030 let mut end_of_options = false;
2035 let mut document_format: Option<(String, Mode)> = None;
2038
2039 let deps_mode = explicit_mode(args) == Some(Mode::Deps);
2042 let mut iter = args.iter();
2043 while let Some(arg) = iter.next() {
2044 if end_of_options {
2045 resources.push(arg.to_string());
2046 continue;
2047 }
2048 if extensions.parse_option(arg, &mut iter)? {
2049 continue;
2050 }
2051 if arg == "--format" {
2052 let named = iter
2053 .clone()
2054 .next()
2055 .and_then(|value| documents::format_mode(value).map(|mode| (value.clone(), mode)));
2056 if let Some(named) = named {
2057 iter.next();
2058 document_format = Some(named);
2059 data.format = None;
2060 continue;
2061 }
2062 document_format = None;
2063 }
2064 if data
2065 .parse_option(arg, &mut iter)
2066 .map_err(|err| match arg.as_str() {
2067 "--format" => documents::FORMAT_USAGE.to_string(),
2068 _ => err,
2069 })?
2070 {
2071 continue;
2072 }
2073 if tool_options.parse_option(arg, &mut iter)? {
2074 continue;
2075 }
2076 if log_options.parse_option(arg, &mut iter)? {
2079 continue;
2080 }
2081 if viewer_options.parse_option(arg, &mut iter)? {
2082 continue;
2083 }
2084 if graph_sources.parse_option(arg, &mut iter, deps_mode)? {
2085 continue;
2086 }
2087 if chart.parse_option(arg, &mut iter)? {
2088 continue;
2089 }
2090 if schema_options.parse_option(arg) {
2091 continue;
2092 }
2093 if profile_options.parse_option(arg, &mut iter)? {
2094 continue;
2095 }
2096 if rule_options.parse_option(arg, &mut iter)? {
2097 continue;
2098 }
2099 if matches!(
2100 arg.as_str(),
2101 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
2102 ) {
2103 paging_explicit = true;
2104 }
2105 match arg.as_str() {
2106 "--" => end_of_options = true,
2107 "--help" => {
2109 let no_color = no_color || cli_spec::no_color_requested(args);
2110 let help = command_word(args)
2112 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
2113 match help {
2114 Some(help) => authoring::out(&format!("{help}\n")),
2115 None => print_help(no_color),
2116 }
2117 return Ok(None);
2118 }
2119 "-V" | "-v" | "--version" => {
2120 println!("rich (rs-rich-cli) {VERSION}");
2121 return Ok(None);
2122 }
2123 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
2124 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
2125 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
2128 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
2129 "--csv" => set_mode(&mut mode, Mode::Csv)?,
2130 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
2131 "--rst" => set_mode(&mut mode, Mode::Rst)?,
2132 "--org" => set_mode(&mut mode, Mode::Org)?,
2133 "--asciidoc" | "--adoc" => set_mode(&mut mode, Mode::AsciiDoc)?,
2134 "--man" | "--roff" => set_mode(&mut mode, Mode::Man)?,
2135 "--gif" => set_mode(&mut mode, Mode::Gif)?,
2136 "--diff" => set_mode(&mut mode, Mode::Diff)?,
2137 "--image" => set_mode(&mut mode, Mode::Image)?,
2138 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
2139 "--log" => set_mode(&mut mode, Mode::Log)?,
2140 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
2141 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
2142 "--highlighter" => {
2143 let value = iter
2144 .next()
2145 .ok_or("--highlighter requires a code highlighter's name")?;
2146 highlighter = Some(value.clone());
2147 }
2148 "--code-theme" => {
2149 let value = iter.next().ok_or("--code-theme requires a theme name")?;
2150 code_theme = Some(value.clone());
2151 }
2152 "--plugin" => {
2153 let path = iter.next().ok_or("--plugin requires PATH")?;
2154 plugins.push(path.clone());
2155 }
2156 "--mermaid-backend" => {
2157 let value = iter
2158 .next()
2159 .ok_or("--mermaid-backend requires text, mmdc or off")?;
2160 mermaid_backend = Some(MermaidBackend::parse(value)?);
2161 }
2162 "--dot-backend" => {
2163 let value = iter
2164 .next()
2165 .ok_or("--dot-backend requires text, graphviz or off")?;
2166 dot_backend = Some(sources::DotBackend::parse(value)?);
2167 }
2168 "--log-presentation" => {
2169 let value = iter
2170 .next()
2171 .ok_or("--log-presentation requires plain or rich")?;
2172 if !matches!(value.as_str(), "plain" | "rich") {
2173 return Err("--log-presentation requires plain or rich".into());
2174 }
2175 log_presentation = value.clone();
2176 }
2177 "--report" => {
2178 let value = iter.next().map(String::as_str);
2179 report_format.apply_option("--report", value)?;
2180 }
2181 "--machine-json" => {
2182 report_format.apply_option("--machine-json", None)?;
2183 }
2184 "--progress" => progress = true,
2185 "--no-progress" => progress = false,
2186 "--micro-project" => micro_project = true,
2187 "--no-micro-project" => micro_project = false,
2188 "--theme-style" => {
2189 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2190 let (name, value) = binding
2191 .split_once('=')
2192 .ok_or("--theme-style requires NAME=STYLE")?;
2193 config::validate_theme_binding(name, value)?;
2194 let style = Style::parse(value)
2195 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2196 theme_styles.insert(name.to_owned(), style);
2197 }
2198 "--theme-file" => {
2199 let path = iter.next().ok_or("--theme-file requires PATH")?;
2200 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2201 }
2202 "--image-color" => {
2203 const USAGE: &str =
2204 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2205 let value = iter.next().ok_or(USAGE)?;
2206 if !matches!(
2207 value.as_str(),
2208 "truecolor" | "ansi256" | "ansi16" | "grayscale"
2209 ) {
2210 return Err(USAGE.into());
2211 }
2212 image_color = Some(value.clone());
2213 }
2214 flag @ ("--image-max-width" | "--image-max-height") => {
2215 let usage = format!("{flag} requires a positive integer");
2216 let value = iter
2217 .next()
2218 .and_then(|v| v.parse::<usize>().ok())
2219 .filter(|v| *v > 0)
2220 .ok_or(usage)?;
2221 if flag == "--image-max-width" {
2222 image_max_width = Some(value);
2223 } else {
2224 image_max_height = Some(value);
2225 }
2226 }
2227 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2228 let gamma = flag == "--image-gamma";
2229 let usage = if gamma {
2230 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2231 } else {
2232 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2233 };
2234 let value = iter
2235 .next()
2236 .and_then(|v| v.parse::<f32>().ok())
2237 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2238 .ok_or(usage)?;
2239 match flag {
2240 "--image-brightness" => image_brightness = Some(value),
2241 "--image-contrast" => image_contrast = Some(value),
2242 _ => image_gamma = Some(value),
2243 }
2244 }
2245 "--image-flip-horizontal" => image_flip_horizontal = true,
2246 "--no-image-flip-horizontal" => image_flip_horizontal = false,
2247 "--image-flip-vertical" => image_flip_vertical = true,
2248 "--no-image-flip-vertical" => image_flip_vertical = false,
2249 "--image-grayscale" => image_grayscale = true,
2250 "--no-image-grayscale" => image_grayscale = false,
2251 "--image-rotate" => {
2252 let value = iter
2253 .next()
2254 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2255 image_rotate = Some(match value.as_str() {
2256 "0" => 0,
2257 "90" => 90,
2258 "180" => 180,
2259 "270" => 270,
2260 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2261 });
2262 }
2263 "--image-dither" => {
2264 const USAGE: &str =
2265 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2266 let value = iter.next().ok_or(USAGE)?;
2267 if !matches!(
2268 value.as_str(),
2269 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2270 ) {
2271 return Err(USAGE.into());
2272 }
2273 image_dither = Some(value.clone());
2274 }
2275 "--image-color-distance" => {
2276 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2277 let value = iter.next().ok_or(USAGE)?;
2278 if !matches!(value.as_str(), "rgb" | "oklab") {
2279 return Err(USAGE.into());
2280 }
2281 image_color_distance = Some(value.clone());
2282 }
2283 "--image-mode" => {
2284 let value = iter.next().ok_or(
2285 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2286 )?;
2287 image_mode = value.parse()?;
2288 }
2289 "--image-fit" => {
2290 let value = iter
2291 .next()
2292 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2293 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2294 return Err("--image-fit requires contain, cover, stretch or native".into());
2295 }
2296 image_fit = Some(value.clone());
2297 }
2298 "--image-anchor" => {
2299 let value = iter.next().ok_or("--image-anchor requires a position")?;
2300 if !matches!(
2301 value.as_str(),
2302 "center"
2303 | "top"
2304 | "bottom"
2305 | "left"
2306 | "right"
2307 | "top-left"
2308 | "top-right"
2309 | "bottom-left"
2310 | "bottom-right"
2311 ) {
2312 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2313 }
2314 image_anchor = Some(value.clone());
2315 }
2316 "--image-background" => {
2317 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2318 let value = iter.next().ok_or(USAGE)?;
2319 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2320 }
2321 "--height" => {
2322 let value = iter.next().ok_or("--height requires a number")?;
2323 let rows: usize = value
2324 .parse()
2325 .map_err(|_| format!("invalid height '{value}'"))?;
2326 if rows == 0 {
2327 return Err("--height must be at least 1".into());
2328 }
2329 height = Some(rows);
2330 }
2331 "--threshold" => {
2332 let value = iter
2333 .next()
2334 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2335 let percent: f32 = value
2336 .parse()
2337 .map_err(|_| format!("invalid threshold '{value}'"))?;
2338 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2343 return Err(format!(
2344 "threshold must be a percentage between 0 and 100, got '{value}'"
2345 ));
2346 }
2347 diff_threshold = Some(percent);
2348 }
2349 "--loop" => {
2350 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2351 loops = Some(
2352 value
2353 .parse()
2354 .map_err(|_| format!("invalid loop count {value:?}"))?,
2355 );
2356 }
2357 "--speed" | "--fps" | "--max-frames" | "--max-duration" => {
2358 let value = iter
2359 .next()
2360 .ok_or_else(|| format!("{arg} requires a value"))?;
2361 animation.set(arg, value)?;
2362 }
2363 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2364 "-l" | "--left" => justify = Some(Justify::Left),
2365 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2366 "-r" | "--right" => {}
2367 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2368 "-c" | "--center" => {}
2369 "-L" | "--text-left" => text_flags[0] = true,
2370 "-R" | "--text-right" => text_flags[1] = true,
2371 "-C" | "--text-center" => text_flags[2] = true,
2372 "-F" | "--text-full" => text_flags[3] = true,
2373 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2374 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2375 "-n" | "--line-numbers" => source.line_numbers = true,
2376 "-g" | "--guides" => source.guides = true,
2377 "--lexer" => {
2378 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2379 }
2380 "--no-wrap" => source.no_wrap = true,
2381 "--infer" => source.infer = true,
2382 "--emoji" => emoji = true,
2383 "--soft" => soft = true,
2384 "-W" | "--max-width" => {
2385 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2386 let size: i64 = value
2388 .parse()
2389 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2390 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2391 }
2392 "--rule-style" => {
2393 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2394 rule_style = Some(
2395 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2396 );
2397 }
2398 "--rule-char" => {
2399 rule_char = Some(
2400 iter.next()
2401 .ok_or("--rule-char requires a CHARACTER")?
2402 .clone(),
2403 );
2404 }
2405 "--force-terminal" => force_terminal = true,
2406 "--no-color" => no_color = true,
2407 "--pager" => {
2408 pager = true;
2409 auto_pager = false;
2410 }
2411 "--auto-pager" => {
2412 auto_pager = true;
2413 pager = false;
2414 }
2415 "--no-pager" => {
2416 pager = false;
2417 auto_pager = false;
2418 }
2419 "--no-auto-pager" => auto_pager = false,
2420 "--color" => no_color = false,
2421 "--no-watch" => watch = false,
2422 "--no-watch-cache" => watch_cache = false,
2423 "--no-watch-poll" => watch_poll = false,
2424 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2425 "--no-batch" => batch = false,
2426 "--no-continue-on-error" => continue_on_error = false,
2427 "--no-overwrite" => {
2428 overwrite = false;
2429 collision = CollisionPolicy::Error;
2430 }
2431 "--no-sanitize" => sanitize = Some(false),
2432 "--dry-run" => dry_run = true,
2433 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2434 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2435 "--batch-input-root" => {
2436 batch_paths.input_root = Some(fs_path(
2437 iter.next().ok_or("--batch-input-root requires a path")?,
2438 ))
2439 }
2440 "--batch-name-template" => {
2441 batch_paths.template = Some(
2442 batch_paths::FilenameTemplate::parse(
2443 iter.next()
2444 .ok_or("--batch-name-template requires a template")?,
2445 )
2446 .map_err(|e| e.to_string())?,
2447 )
2448 }
2449 "--watch" => watch = true,
2450 "--watch-cache" => watch_cache = true,
2451 "--watch-poll" => watch_poll = true,
2452 "--watch-exit-on-error" => watch_exit_on_error = true,
2453 "--watch-debounce" => {
2454 let value = iter
2455 .next()
2456 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2457 let seconds = value
2458 .parse::<f64>()
2459 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2460 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2461 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2462 }
2463 watch_debounce = Some(seconds);
2464 }
2465 "--watch-interval" | "--interval" => {
2466 let value = iter
2467 .next()
2468 .ok_or("--watch-interval requires seconds greater than zero")?;
2469 watch_interval = value
2470 .parse::<f64>()
2471 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2472 if !watch_interval.is_finite() || watch_interval <= 0.0 || watch_interval > 86400.0
2473 {
2474 return Err(
2475 "--watch-interval must be greater than zero and at most 86400 seconds"
2476 .into(),
2477 );
2478 }
2479 }
2480 "--batch" => batch = true,
2481 "--continue-on-error" => continue_on_error = true,
2482 "--overwrite" => {
2483 overwrite = true;
2484 collision = CollisionPolicy::Overwrite;
2485 }
2486 "--collision" => {
2487 collision = match iter
2488 .next()
2489 .ok_or("--collision requires error, overwrite, or suffix")?
2490 .as_str()
2491 {
2492 "error" => CollisionPolicy::Error,
2493 "overwrite" => CollisionPolicy::Overwrite,
2494 "suffix" => CollisionPolicy::Suffix,
2495 other => return Err(format!("unknown collision policy {other:?}")),
2496 };
2497 overwrite = collision == CollisionPolicy::Overwrite;
2498 }
2499 "--jobs" => {
2500 jobs = iter
2501 .next()
2502 .ok_or("--jobs requires a positive number")?
2503 .parse()
2504 .map_err(|_| "--jobs requires a positive number".to_string())?;
2505 if jobs == 0 {
2506 return Err("--jobs must be at least 1".into());
2507 }
2508 }
2509 "--sanitize" => sanitize = Some(true),
2510 "-y" | "--hyperlinks" => hyperlinks = true,
2514 "--export-html" | "-o" => {
2518 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2519 }
2520 "--export-svg" => {
2521 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2522 }
2523 "--export-png" => {
2524 export_png = Some(iter.next().ok_or("--export-png requires a PATH")?.clone());
2525 }
2526 "-a" | "--panel" => {
2527 let value = iter.next().ok_or("--panel requires a box name")?;
2528 panel = parse_box(value)?;
2529 }
2530 "-d" | "--padding" => {
2531 let value = iter.next().ok_or("--padding requires a value")?;
2532 padding = Some(parse_padding(value)?);
2533 }
2534 "-e" | "--expand" => expand = true,
2535 "--title" => {
2536 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2537 }
2538 "--caption" => {
2539 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2540 }
2541 "-s" | "--style" => {
2546 let value = iter.next().ok_or("--style requires a value")?;
2547 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2548 }
2549 "-S" | "--panel-style" => {
2550 let value = iter.next().ok_or("--panel-style requires a value")?;
2551 panel_style =
2552 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2553 }
2554 "-w" | "--width" => {
2555 let value = iter.next().ok_or("--width requires a number")?;
2556 let columns: usize = value.parse().map_err(|_| {
2557 if std::path::Path::new(value).exists() {
2561 format!("--width needs a number, but got the file '{value}' — a value is missing")
2562 } else {
2563 format!("invalid width '{value}'")
2564 }
2565 })?;
2566 if columns == 0 {
2567 return Err("--width must be at least 1".into());
2568 }
2569 width = Some(columns);
2570 }
2571 other if other.starts_with('-') && other != "-" => {
2572 return Err(format!("unknown option {other:?}"));
2573 }
2574 other
2575 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2576 {
2577 ansi_word = other == "ansi";
2578 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2579 }
2580 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2581 other => resources.push(other.to_string()),
2582 }
2583 }
2584
2585 if let Some((name, document)) = document_format {
2589 if mode != Mode::Auto && mode != document {
2590 return Err(format!(
2591 "--format {name} cannot be combined with --{}",
2592 mode_name(mode)
2593 ));
2594 }
2595 mode = document;
2596 }
2597 if data.format.is_some() {
2600 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2601 return Err(format!(
2602 "--format cannot be combined with --{}",
2603 mode_name(mode)
2604 ));
2605 }
2606 if mode == Mode::Auto && resources.is_empty() && !batch {
2607 resources.push("-".into());
2608 }
2609 }
2610 if let Some(flag) = data
2611 .inspect_only_option(matches!(mode, Mode::Capture | Mode::Log))
2612 .filter(|_| mode != Mode::Inspect)
2613 {
2614 if flag == "--redact" {
2615 return Err(
2616 "--redact only has an effect with --inspect, `rich capture` or `rich log`".into(),
2617 );
2618 }
2619 return Err(format!("{flag} only has an effect with --inspect"));
2620 }
2621 if let Some(flag) = data.transform_option() {
2622 if !transforms_apply(mode) {
2623 return Err(format!(
2624 "{flag} only has an effect on {}",
2625 transform_modes(flag)
2626 ));
2627 }
2628 data.check_transforms(mode == Mode::Inspect)?;
2629 }
2630 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2631 return Err(format!("{flag} only has an effect with --diff"));
2632 }
2633 if let Some(flag) = tool_options
2634 .ansi_option()
2635 .filter(|_| mode != Mode::AnsiExplain)
2636 {
2637 return Err(format!("{flag} only has an effect with --ansi-explain"));
2638 }
2639 if mode == Mode::Log {
2640 if viewer_options
2642 .given()
2643 .iter()
2644 .any(|(flag, _)| *flag == "--group")
2645 {
2646 return Err("--group requires trace, span or correlation with `rich log`".into());
2647 }
2648 log_options.validate(resources.first().map(String::as_str))?;
2649 if log_options.follows() && (watch || batch) {
2650 return Err("--follow cannot be combined with --watch or --batch".into());
2651 }
2652 }
2653 if mode == Mode::Profile {
2656 if let Some(depth) = graph_sources.take_depth() {
2657 profile_options.set_depth(depth)?;
2658 }
2659 }
2660 for (flag, commands) in viewer_options
2661 .given()
2662 .into_iter()
2663 .chain(graph_sources.given())
2664 .chain(chart.given())
2665 .chain(schema_options.given())
2666 .chain(profile_options.given())
2667 .chain(log_options.given())
2668 {
2669 if !commands.contains(&mode_name(mode)) {
2670 return Err(format!(
2671 "{flag} only has an effect with `rich {}`",
2672 commands.join("` or `rich ")
2673 ));
2674 }
2675 }
2676 if mode == Mode::Capture && resources.is_empty() {
2677 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2678 }
2679 if mode == Mode::View && !paging_explicit {
2680 auto_pager = true;
2681 }
2682 data.validate()?;
2683
2684 if watch && (batch || pager || auto_pager) {
2685 return Err("--watch cannot be combined with --batch or paging".into());
2686 }
2687
2688 if batch {
2692 if mode.accepts_multiple_resources() {
2693 return Err(format!(
2694 "--batch cannot be combined with {}",
2695 if mode == Mode::Diff {
2696 "--diff"
2697 } else {
2698 "--gif"
2699 }
2700 ));
2701 }
2702 if resources.is_empty() {
2703 return Err("--batch needs at least one file, directory or glob".into());
2704 }
2705 if pager || auto_pager {
2707 return Err("--pager cannot be combined with --batch".into());
2708 }
2709 }
2710
2711 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2714 return Err(
2715 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2716 );
2717 }
2718 if mode == Mode::Diff && tool_options.conflicts() {
2721 if resources.len() != 1 {
2722 return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2723 }
2724 if diff_threshold.is_some() {
2725 return Err("--threshold cannot be combined with --conflicts".into());
2726 }
2727 }
2728 if mode == Mode::Image {
2729 if resources.is_empty() {
2730 return Err(
2731 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2732 );
2733 }
2734 if image_mode == ImageMode::None {
2735 return Err(
2736 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2737 .into(),
2738 );
2739 }
2740 }
2741 let quantized = matches!(
2742 image_color.as_deref(),
2743 Some("ansi256" | "ansi16" | "grayscale")
2744 );
2745 if quantized && image_mode == ImageMode::Braille {
2746 return Err(format!(
2747 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2748 image_color.as_deref().unwrap()
2749 ));
2750 }
2751 for (flag, value, neutral) in [
2752 ("--image-dither", image_dither.as_deref(), "none"),
2753 (
2754 "--image-color-distance",
2755 image_color_distance.as_deref(),
2756 "rgb",
2757 ),
2758 ] {
2759 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2760 return Err(format!(
2761 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2762 ));
2763 }
2764 }
2765 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2766 return Err("--image-anchor requires --image-fit cover".into());
2767 }
2768 if (batch_paths.preserve_dirs
2769 || batch_paths.template.is_some()
2770 || batch_paths.input_root.is_some())
2771 && !batch
2772 {
2773 return Err("batch path options require --batch".into());
2774 }
2775 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2776 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2777 }
2778 if dry_run && !batch {
2779 return Err("--dry-run requires --batch".into());
2780 }
2781 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2782 return Err("--jobs greater than 1 requires --batch with a file export".into());
2783 }
2784 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2786 return Err("--image-fit requires --height to define the target rectangle".into());
2787 }
2788 let effective_mode = if mode == Mode::Auto {
2793 detect_mode(resources.first().map(String::as_str))
2794 } else {
2795 mode
2796 };
2797 extensions.validate(
2798 effective_mode == Mode::Gif,
2799 effective_mode.allows_extensions()
2800 && !(mode == Mode::Auto && resources.is_empty())
2801 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2802 )?;
2803 if export_png.is_some() && !cfg!(feature = "png") {
2804 return Err("--export-png needs a build with the png feature".into());
2805 }
2806 if export_png.is_some() && batch {
2807 return Err("--export-png cannot be combined with --batch".into());
2808 }
2809 let exporting = export_html.is_some() || export_svg.is_some() || export_png.is_some();
2810 if exporting {
2811 let unsupported = if effective_mode == Mode::Gif {
2812 Some("--gif")
2813 } else if mode == Mode::Auto && resources.is_empty() {
2814 Some("the capability demo")
2815 } else {
2816 None
2817 };
2818 if let Some(what) = unsupported {
2819 return Err(format!(
2820 "--export-html/--export-svg/--export-png cannot capture {what}"
2821 ));
2822 }
2823 }
2824
2825 let text_justify = [
2831 Justify::Left,
2832 Justify::Right,
2833 Justify::Center,
2834 Justify::Full,
2835 ]
2836 .into_iter()
2837 .zip(text_flags)
2838 .find_map(|(justify, given)| given.then_some(justify));
2839 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2842 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2843 let orphans = [
2844 (
2845 "--head/--tail",
2846 source.head.is_some() || source.tail.is_some(),
2847 "--syntax, --csv or --ipynb",
2848 source_mode || effective_mode == Mode::Csv,
2849 ),
2850 (
2851 "--infer",
2852 source.infer,
2853 "--csv",
2854 effective_mode == Mode::Csv,
2855 ),
2856 (
2857 "--line-numbers",
2858 source.line_numbers,
2859 "--syntax or --ipynb",
2860 source_mode,
2861 ),
2862 (
2863 "--guides",
2864 source.guides,
2865 "--syntax or --ipynb",
2866 source_mode,
2867 ),
2868 (
2869 "--lexer",
2870 source.lexer.is_some(),
2871 "--syntax, --ipynb, --rst, --org, --asciidoc or --man",
2872 source_mode
2873 || matches!(
2874 effective_mode,
2875 Mode::Rst | Mode::Org | Mode::AsciiDoc | Mode::Man
2876 ),
2877 ),
2878 (
2879 "--no-wrap",
2880 source.no_wrap,
2881 "--syntax, --ipynb, --print or --rule",
2882 source_mode || print_mode,
2883 ),
2884 (
2885 "--text-left/--text-right/--text-center/--text-full",
2886 text_justify.is_some(),
2887 "--print or --rule",
2888 print_mode,
2889 ),
2890 (
2891 "--rule-style",
2892 rule_style.is_some(),
2893 "--rule",
2894 effective_mode == Mode::Rule,
2895 ),
2896 (
2897 "--rule-char",
2898 rule_char.is_some(),
2899 "--rule",
2900 effective_mode == Mode::Rule,
2901 ),
2902 (
2905 "--soft",
2906 soft,
2907 "a mode other than --jsonl or --log",
2908 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2909 ),
2910 (
2911 "--max-width",
2912 max_width.is_some(),
2913 "a mode other than --jsonl or --log",
2914 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2915 ),
2916 (
2917 "--threshold",
2918 diff_threshold.is_some(),
2919 "--diff",
2920 mode == Mode::Diff,
2921 ),
2922 (
2923 "--image-mode",
2924 image_mode != ImageMode::Auto,
2925 "--diff/--image",
2926 mode == Mode::Diff || mode == Mode::Image,
2927 ),
2928 ("--height", height.is_some(), "--image", mode == Mode::Image),
2929 (
2930 "--image-rotate",
2931 image_rotate.is_some(),
2932 "--image",
2933 mode == Mode::Image,
2934 ),
2935 (
2936 "--image-flip-horizontal",
2937 image_flip_horizontal,
2938 "--image",
2939 mode == Mode::Image,
2940 ),
2941 (
2942 "--image-flip-vertical",
2943 image_flip_vertical,
2944 "--image",
2945 mode == Mode::Image,
2946 ),
2947 (
2948 "--image-grayscale",
2949 image_grayscale,
2950 "--image",
2951 mode == Mode::Image,
2952 ),
2953 (
2954 "--image-color",
2955 image_color.is_some(),
2956 "--image or --gif",
2957 mode == Mode::Image || effective_mode == Mode::Gif,
2958 ),
2959 (
2960 "--image-max-width",
2961 image_max_width.is_some(),
2962 "--image",
2963 mode == Mode::Image,
2964 ),
2965 (
2966 "--image-max-height",
2967 image_max_height.is_some(),
2968 "--image",
2969 mode == Mode::Image,
2970 ),
2971 (
2972 "--image-brightness",
2973 image_brightness.is_some(),
2974 "--image",
2975 mode == Mode::Image,
2976 ),
2977 (
2978 "--image-contrast",
2979 image_contrast.is_some(),
2980 "--image",
2981 mode == Mode::Image,
2982 ),
2983 (
2984 "--image-gamma",
2985 image_gamma.is_some(),
2986 "--image",
2987 mode == Mode::Image,
2988 ),
2989 (
2990 "--image-dither",
2991 image_dither.is_some(),
2992 "--image or --gif",
2993 mode == Mode::Image || effective_mode == Mode::Gif,
2994 ),
2995 (
2996 "--image-color-distance",
2997 image_color_distance.is_some(),
2998 "--image or --gif",
2999 mode == Mode::Image || effective_mode == Mode::Gif,
3000 ),
3001 (
3002 "--image-fit",
3003 image_fit.is_some(),
3004 "--image",
3005 mode == Mode::Image,
3006 ),
3007 (
3008 "--image-background",
3009 image_background.is_some(),
3010 "--image",
3011 mode == Mode::Image,
3012 ),
3013 (
3014 "--loop",
3015 loops.is_some(),
3016 "--gif",
3017 effective_mode == Mode::Gif,
3018 ),
3019 (
3020 "--speed",
3021 animation.speed.is_some(),
3022 "--gif",
3023 effective_mode == Mode::Gif,
3024 ),
3025 (
3026 "--fps",
3027 animation.fps.is_some(),
3028 "--gif",
3029 effective_mode == Mode::Gif,
3030 ),
3031 (
3032 "--max-frames",
3033 animation.max_frames.is_some(),
3034 "--gif",
3035 effective_mode == Mode::Gif,
3036 ),
3037 (
3038 "--max-duration",
3039 animation.max_duration.is_some(),
3040 "--gif",
3041 effective_mode == Mode::Gif,
3042 ),
3043 (
3048 "--panel-style",
3049 panel_style.is_some(),
3050 "--panel",
3051 panel.is_some(),
3052 ),
3053 (
3054 "--expand",
3055 expand,
3056 "--panel/--padding",
3057 panel.is_some() || padding.is_some(),
3058 ),
3059 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
3060 ("--watch-cache", watch_cache, "--watch", watch),
3061 (
3062 "--watch-debounce",
3063 watch_debounce.is_some(),
3064 "--watch",
3065 watch,
3066 ),
3067 ("--watch-poll", watch_poll, "--watch", watch),
3068 (
3069 "--watch-exit-on-error",
3070 watch_exit_on_error,
3071 "--watch",
3072 watch,
3073 ),
3074 ];
3075 if let Some((flag, _, needs, _)) = orphans
3076 .iter()
3077 .find(|(_, given, _, mode_present)| *given && !*mode_present)
3078 {
3079 return Err(format!("{flag} only has an effect with {needs}"));
3080 }
3081
3082 let demo = mode == Mode::Auto && resources.is_empty();
3085 if effective_mode.draws_directly() || demo {
3086 let mode_name = if effective_mode == Mode::Gif {
3087 "--gif"
3088 } else if effective_mode == Mode::Image {
3089 "--image"
3090 } else if effective_mode.streams_records() {
3091 mode_name(effective_mode)
3092 } else if demo {
3093 "the capability demo"
3094 } else {
3095 "--diff"
3096 };
3097 let unsupported = [
3098 (
3099 "--pager",
3100 (pager || auto_pager)
3101 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
3102 ),
3103 (
3104 "--export-html/--export-svg/--export-png",
3105 (export_html.is_some() || export_svg.is_some() || export_png.is_some())
3106 && effective_mode.streams_records(),
3107 ),
3108 ("--width", demo && width.is_some()),
3109 ("--theme-style", demo && !theme_styles.is_empty()),
3110 ("--theme-file", demo && !theme_file_styles.is_empty()),
3111 (
3112 "--hyperlinks",
3113 hyperlinks && (effective_mode.streams_records() || demo),
3114 ),
3115 ("--panel", panel.is_some()),
3116 ("--padding", padding.is_some()),
3117 ("--title", title.is_some()),
3118 ("--caption", caption.is_some()),
3119 ("--style", style.is_some()),
3120 ("--panel-style", panel_style.is_some()),
3121 ("--expand", expand),
3122 ("--left/--center/--right", justify.is_some()),
3123 ];
3124 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
3125 return Err(format!("{flag} cannot be combined with {mode_name}"));
3126 }
3127 }
3128 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
3131 return Err("only one resource may be given (except with --gif or --watch)".into());
3132 }
3133 let sanitize = sanitize.unwrap_or_else(|| {
3138 mode == Mode::View
3139 || mode == Mode::Schema
3140 || mode == Mode::Log
3141 || (mode == Mode::Diff
3142 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
3143 });
3144 let resource = resources.first().map(|resource| {
3146 if print_mode {
3147 text_arg(resource)
3148 } else {
3149 resource.clone()
3150 }
3151 });
3152 let title = title.map(|title| text_arg(&title));
3153 let caption = caption.map(|caption| text_arg(&caption));
3154 let rule_char = rule_char.map(|characters| text_arg(&characters));
3155
3156 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
3158 Ok(Some(Cli {
3159 mode,
3160 resource,
3161 resources,
3162 loops,
3163 animation,
3164 diff_threshold,
3165 image_mode,
3166 image_fit,
3167 image_anchor,
3168 image_background,
3169 image_color,
3170 image_dither,
3171 image_color_distance,
3172 image_rotate,
3173 image_flip_horizontal,
3174 image_flip_vertical,
3175 image_grayscale,
3176 image_max_width,
3177 image_max_height,
3178 image_brightness,
3179 image_contrast,
3180 image_gamma,
3181 log_presentation,
3182 mermaid_backend,
3183 dot_backend,
3184 graph_sources,
3185 chart,
3186 schema: schema_options,
3187 profile: profile_options,
3188 rules: rule_options,
3189 highlighter,
3190 code_theme,
3191 plugins,
3192 micro_project,
3193 theme_styles,
3194 theme_file_styles,
3195 height,
3196 extensions,
3197 width,
3198 justify,
3199 text_justify,
3200 emoji,
3201 soft,
3202 max_width,
3203 rule_style,
3204 rule_char,
3205 force_terminal,
3206 source,
3207 no_color,
3208 export_html,
3209 export_svg,
3210 export_png,
3211 panel,
3212 padding,
3213 title,
3214 caption,
3215 panel_style,
3216 style,
3217 expand,
3218 pager,
3219 auto_pager,
3220 hyperlinks,
3221 sanitize,
3222 report_format,
3223 watch,
3224 watch_interval,
3225 watch_cache,
3226 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
3227 watch_poll,
3228 watch_exit_on_error,
3229 batch,
3230 progress,
3231 continue_on_error,
3232 jobs,
3233 dry_run,
3234 batch_paths,
3235 worker_args: worker_args(args),
3236 overwrite,
3237 collision,
3238 data,
3239 tools: tool_options,
3240 viewers: viewer_options,
3241 logs: log_options,
3242 }))
3243}
3244
3245fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3255 use std::io::{Seek, SeekFrom};
3256 let resource = cli.resource.as_deref();
3257 let name = resource.unwrap_or("<stdin>");
3258 let window = |bytes: Vec<u8>| {
3259 let start = usize::try_from(offset)
3260 .unwrap_or(usize::MAX)
3261 .min(bytes.len());
3262 let mut rest = bytes[start..].to_vec();
3263 let more = rest.len() as u64 > max;
3264 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3265 (rest, more)
3266 };
3267 if let Some(url) = resource.filter(|r| is_url(r)) {
3268 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3269 }
3270 if cli.extensions.encoding.is_some() {
3271 return read_resource_limited(
3272 resource,
3273 cli.extensions.encoding,
3274 Some(controls::INSPECT_LIMIT),
3275 )
3276 .map(|text| window(text.into_bytes()))
3277 .map_err(|err| format!("cannot read {name}: {err}"));
3278 }
3279 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3280 let take = max.saturating_add(1);
3282 let mut bytes = Vec::new();
3283 match resource.filter(|r| *r != "-") {
3284 Some(path) if fs_path(path).is_dir() => {
3285 return Err(format!("cannot read {path}: it is a directory"));
3286 }
3287 Some(path) => {
3288 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3289 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3291 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3292 .map_err(error)?;
3293 }
3294 file.take(take).read_to_end(&mut bytes).map_err(error)?;
3295 }
3296 None => {
3297 let mut stdin = std::io::stdin().lock();
3298 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3299 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3300 }
3301 }
3302 let more = bytes.len() as u64 > max;
3303 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3304 Ok((bytes, more))
3305}
3306
3307fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3309 let mut bytes = Vec::new();
3310 match limit {
3311 None => {
3312 let mut reader = reader;
3313 reader.read_to_end(&mut bytes)?;
3314 }
3315 Some(limit) => {
3316 reader
3317 .take(limit.saturating_add(1))
3318 .read_to_end(&mut bytes)?;
3319 if bytes.len() as u64 > limit {
3320 return Err(std::io::Error::other(format!(
3321 "larger than {}, the most this command reads",
3322 controls::size(limit)
3323 )));
3324 }
3325 }
3326 }
3327 Ok(bytes)
3328}
3329
3330fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3343 read_resource_limited(resource, encoding, None)
3344}
3345
3346fn read_resource_limited(
3350 resource: Option<&str>,
3351 encoding: Option<Encoding>,
3352 limit: Option<u64>,
3353) -> std::io::Result<String> {
3354 let content = match resource {
3355 Some(path) if path != "-" => {
3356 let file = &fs_path(path);
3357 if file.is_dir() {
3360 return Err(std::io::Error::new(
3361 std::io::ErrorKind::InvalidInput,
3362 "is a directory, not a file",
3363 ));
3364 }
3365 let bytes = match limit {
3366 None => std::fs::read(file)?,
3367 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3368 };
3369 if let Some(encoding) = encoding {
3370 return encoding.decode(&bytes).map(normalize_newlines);
3371 }
3372 if has_utf16_bom(&bytes) {
3373 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
3374 }
3375 match String::from_utf8(bytes) {
3376 Ok(text) => text,
3377 Err(_) if looks_like_image(path) => {
3382 return Err(std::io::Error::new(
3383 std::io::ErrorKind::InvalidData,
3384 "looks like an image; use `rich --image <path>` to view it, or \
3385 `rich --diff <before> <after>` to compare two images",
3386 ))
3387 }
3388 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3389 }
3390 }
3391 _ => {
3392 if std::io::stdin().is_terminal() {
3393 let eof = if cfg!(windows) {
3394 "Ctrl-Z then Enter"
3395 } else {
3396 "Ctrl-D"
3397 };
3398 eprintln!("rich: reading stdin; finish input with {eof}");
3399 }
3400 let buffer = read_limited(std::io::stdin(), limit)?;
3401 decode_strict(buffer, encoding)?
3402 }
3403 };
3404 Ok(normalize_newlines(if encoding.is_some() {
3405 content
3406 } else {
3407 strip_bom(content)
3408 }))
3409}
3410
3411fn sanitize_json_value(value: &mut serde_json::Value) {
3412 match value {
3413 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3414 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3415 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3416 _ => {}
3417 }
3418}
3419
3420fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3421 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3422 sanitize_json_value(&mut value);
3423 serde_json::to_string(&value)
3424}
3425
3426fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3428 if let Some(encoding) = encoding {
3429 return encoding.decode(&bytes);
3430 }
3431 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3432 std::io::ErrorKind::InvalidData,
3433 if has_utf16_bom(err.as_bytes()) {
3434 "input has a UTF-16 BOM; use --encoding utf-16"
3435 } else {
3436 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3437 },
3438 ))
3439}
3440
3441fn normalize_newlines(text: String) -> String {
3450 const CR: char = '\r';
3451 const LF: char = '\n';
3452 if !text.contains(CR) {
3453 return text;
3454 }
3455 let mut out = String::with_capacity(text.len());
3456 let mut chars = text.chars().peekable();
3457 while let Some(c) = chars.next() {
3458 if c == CR {
3459 if chars.peek() == Some(&LF) {
3462 chars.next();
3463 }
3464 out.push(LF);
3465 } else {
3466 out.push(c);
3467 }
3468 }
3469 out
3470}
3471
3472fn looks_like_image(path: &str) -> bool {
3482 std::path::Path::new(path)
3483 .extension()
3484 .and_then(|e| e.to_str())
3485 .map(|e| e.to_ascii_lowercase())
3486 .is_some_and(|e| {
3487 matches!(
3488 e.as_str(),
3489 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3490 )
3491 })
3492}
3493
3494fn strip_bom(text: String) -> String {
3495 match text.strip_prefix('\u{feff}') {
3496 Some(rest) => rest.to_string(),
3497 None => text,
3498 }
3499}
3500
3501fn is_url(resource: &str) -> bool {
3504 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3505 head.starts_with("http://") || head.starts_with("https://")
3506}
3507
3508fn resource_ext(resource: &str) -> Option<String> {
3512 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3513 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3514 basename
3515 .rsplit_once('.')
3516 .map(|(_, ext)| ext.to_ascii_lowercase())
3517 .filter(|ext| !ext.is_empty())
3518}
3519
3520#[cfg(feature = "fetch")]
3523const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3524
3525#[cfg(feature = "fetch")]
3533fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3534 use std::time::Duration;
3535 let mut response = ureq::get(url)
3536 .config()
3537 .timeout_global(Some(Duration::from_secs(30)))
3538 .http_status_as_error(false)
3541 .build()
3542 .call()
3543 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3544 let content_type = response
3545 .headers()
3546 .get("content-type")
3547 .and_then(|value| value.to_str().ok())
3548 .map(str::to_string);
3549
3550 let mut buffer = Vec::new();
3556 response
3557 .body_mut()
3558 .with_config()
3559 .reader()
3560 .take(MAX_BODY_BYTES + 1)
3561 .read_to_end(&mut buffer)
3562 .map_err(|err| format!("cannot read {url}: {err}"))?;
3563 if buffer.len() as u64 > MAX_BODY_BYTES {
3564 return Err(format!(
3565 "{url} exceeds the {} MiB limit",
3566 MAX_BODY_BYTES / (1024 * 1024)
3567 ));
3568 }
3569 let body =
3570 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3571 Ok((body, content_type))
3572}
3573
3574#[cfg(not(feature = "fetch"))]
3576fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3577 Err(format!(
3578 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3579 ))
3580}
3581
3582fn content_type_mode(content_type: &str) -> Option<Mode> {
3586 let mime = mime_of(content_type);
3587 match mime.as_str() {
3588 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3589 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3590 "text/org" | "text/x-org" => Some(Mode::Org),
3591 "text/asciidoc" | "text/x-asciidoc" => Some(Mode::AsciiDoc),
3592 "text/troff" | "application/x-troff" | "application/x-troff-man" | "text/x-troff-man" => {
3593 Some(Mode::Man)
3594 }
3595 "application/json" | "text/json" => Some(Mode::Json),
3596 "text/csv" => Some(Mode::Csv),
3597 _ => None,
3598 }
3599}
3600
3601fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3605 let mime = mime_of(content_type);
3606 Some(match mime.as_str() {
3607 "text/html" | "application/xhtml+xml" => "html",
3608 "text/css" => "css",
3609 "application/javascript" | "text/javascript" => "javascript",
3610 "application/xml" | "text/xml" => "xml",
3611 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3612 "text/x-rust" => "rust",
3613 "text/x-c" | "text/x-csrc" => "c",
3614 "application/x-sh" | "text/x-shellscript" => "bash",
3615 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3616 "application/toml" | "text/x-toml" => "toml",
3617 _ => return None,
3618 })
3619}
3620
3621fn is_uninformative_ext(ext: &str) -> bool {
3624 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3625}
3626
3627fn mime_of(content_type: &str) -> String {
3630 content_type
3631 .split(';')
3632 .next()
3633 .unwrap_or("")
3634 .trim()
3635 .to_ascii_lowercase()
3636}
3637
3638fn transforms_apply(mode: Mode) -> bool {
3642 matches!(
3643 mode,
3644 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3645 )
3646}
3647
3648fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3652 if let Some(flag) = cli
3653 .data
3654 .transform_option()
3655 .filter(|_| !transforms_apply(mode))
3656 {
3657 return Some(transforms_misplaced(flag, mode));
3658 }
3659 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3660 .then(|| cli.data.check_transforms(true).unwrap_err())
3661}
3662
3663fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3664 format!(
3665 "{flag} only has an effect on {}, not {}",
3666 transform_modes(flag),
3667 mode_name(mode)
3668 )
3669}
3670
3671fn transform_modes(flag: &str) -> &'static str {
3673 if flag == "--transform" {
3674 "text, --print or --syntax"
3675 } else {
3676 "text, --print, --syntax or --inspect"
3677 }
3678}
3679
3680fn detect_mode(resource: Option<&str>) -> Mode {
3681 match resource.and_then(resource_ext).as_deref() {
3682 Some("md") | Some("markdown") => Mode::Markdown,
3683 Some("json") => Mode::Json,
3684 Some("csv") | Some("tsv") => Mode::Csv,
3685 Some("ipynb") => Mode::Ipynb,
3686 Some("rst") => Mode::Rst,
3687 Some("org") if !resource.is_some_and(documents::bare_host) => Mode::Org,
3690 Some("adoc" | "asciidoc") => Mode::AsciiDoc,
3691 Some("man" | "roff" | "mdoc") => Mode::Man,
3692 Some("gif") => Mode::Gif,
3693 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3694 Some("dot") | Some("gv") => Mode::Dot,
3695 Some(_) => Mode::Syntax,
3703 None => Mode::Auto,
3707 }
3708}
3709
3710struct ForceWidth {
3718 child: Box<dyn Renderable>,
3719 width: usize,
3720}
3721
3722impl Renderable for ForceWidth {
3723 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3724 self.child
3725 .rich_render(console, &options.update_width(self.width))
3726 }
3727
3728 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3729 Measurement::new(self.width, self.width)
3730 }
3731}
3732
3733struct Aligned {
3743 child: Box<dyn Renderable>,
3744 width: usize,
3750 justify: Justify,
3751}
3752
3753impl Renderable for Aligned {
3754 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3755 let width = self.width.clamp(1, options.max_width);
3756 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3766 let widths: Vec<usize> = lines
3767 .iter()
3768 .map(|line| line.iter().map(Segment::cell_length).sum())
3769 .collect();
3770 let shape = widths.iter().copied().max().unwrap_or(0);
3774
3775 let excess = options.max_width.saturating_sub(shape);
3776 let (left, right) = match self.justify {
3777 Justify::Right => (excess, 0),
3778 Justify::Center => (excess / 2, excess - excess / 2),
3779 _ => (0, excess),
3780 };
3781 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3782
3783 let mut segments = Vec::new();
3784 let last = lines.len().saturating_sub(1);
3785 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3786 if left > 0 {
3787 segments.push(blank(left));
3788 }
3789 segments.extend(line);
3790 let pad = shape - line_width + right;
3791 if pad > 0 {
3792 segments.push(blank(pad));
3793 }
3794 if index != last {
3795 segments.push(Segment::line());
3796 }
3797 }
3798 segments
3799 }
3800}
3801
3802fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3814 console
3815 .render_lines(renderable, &console.options(), false)
3816 .iter()
3817 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3818 .max()
3819 .unwrap_or(0)
3820}
3821
3822fn micro_pending() -> bool {
3827 #[cfg(feature = "art")]
3828 return micro::drawing_pending();
3829 #[cfg(not(feature = "art"))]
3830 false
3831}
3832
3833fn validate_labels(cli: &Cli) -> Result<(), String> {
3834 for label in cli.title.iter().chain(cli.caption.iter()) {
3835 Text::from_markup(label).map_err(|err| err.to_string())?;
3836 }
3837 Ok(())
3838}
3839
3840fn decorate_and_emit(
3846 cli: &Cli,
3847 console: &Console,
3848 export: &Export,
3849 renderable: Box<dyn Renderable>,
3850 fit: Option<usize>,
3851) -> ExitCode {
3852 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3853}
3854
3855fn decorate_and_emit_with(
3858 cli: &Cli,
3859 console: &Console,
3860 export: &Export,
3861 renderable: Box<dyn Renderable>,
3862 fit: Option<usize>,
3863 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3864) -> ExitCode {
3865 if cli.panel.is_some() {
3866 if let Err(err) = validate_labels(cli) {
3867 return fail(cli, ExitClass::Data, err);
3868 }
3869 }
3870 let max_width = console.width();
3871 let expand = cli.expand || cli.width.is_some();
3873 let mut renderable = renderable;
3874 let mut fit = fit;
3875
3876 if let Some((top, right, bottom, left)) = cli.padding {
3882 let padded: Box<dyn Renderable> =
3883 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3884 fit = fit.map(|width| (width + left + right).min(max_width));
3887 renderable = if expand {
3888 padded
3889 } else {
3890 Box::new(Constrain::new(padded, fit))
3891 };
3892 }
3893
3894 if let Some(box_set) = cli.panel {
3895 let mut panel = Panel::new(renderable).box_set(box_set);
3896 #[cfg(feature = "art")]
3899 let micro_label = |label: &Option<String>| {
3900 label
3901 .as_deref()
3902 .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3903 };
3904 #[cfg(not(feature = "art"))]
3905 let micro_label = |_: &Option<String>| None::<Text>;
3906 let micro_title = micro_label(&cli.title);
3907 if let Some(title) = micro_title.clone() {
3908 panel = panel.title_as_text(title);
3909 } else if let Some(title) = cli.title.clone() {
3910 panel = panel.title(title);
3911 }
3912 if let Some(caption) = micro_label(&cli.caption) {
3913 panel = panel.subtitle_as_text(caption);
3914 } else if let Some(caption) = cli.caption.clone() {
3915 panel = panel.subtitle(caption);
3916 }
3917 if let Some(style) = cli.panel_style.clone() {
3918 panel = panel.border_style(style);
3919 }
3920 let inner = max_width.saturating_sub(4);
3929 let child = fit.unwrap_or(inner).min(inner);
3930 let title_width = cli
3931 .title
3932 .as_deref()
3933 .map(|title| {
3934 let mut title = match µ_title {
3937 Some(title) => {
3938 let mut flat = title.blank_copy();
3939 flat.append(&title.plain().replace('\n', " "), None);
3940 flat
3941 }
3942 None => {
3943 let title = rich::emoji::replace(title);
3944 Text::from_markup(&title.replace('\n', " "))
3945 .expect("title markup validated before rendering")
3946 }
3947 };
3948 title.expand_tabs(8);
3949 cell_len(title.plain()) + 2
3950 })
3951 .unwrap_or(0);
3952 fit = Some(child.max(title_width) + 4);
3953 let panel: Box<dyn Renderable> = Box::new(panel);
3954 renderable = if expand {
3955 panel
3956 } else {
3957 Box::new(Constrain::new(panel, fit))
3958 };
3959 }
3960
3961 if let Some(style) = cli.style.clone() {
3964 renderable = Box::new(Styled::new(renderable, style));
3965 }
3966
3967 if let Some(width) = cli.width.filter(|_| !cli.pager) {
3969 renderable = Box::new(ForceWidth {
3970 child: renderable,
3971 width,
3972 });
3973 fit = Some(width);
3974 }
3975
3976 if let Some(justify) = cli.justify {
3977 renderable = Box::new(Aligned {
3978 child: renderable,
3979 width: fit.unwrap_or(max_width),
3980 justify,
3981 });
3982 }
3983
3984 #[cfg(feature = "art")]
3989 if let Some(registry) =
3990 micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
3991 {
3992 renderable = micro::drawn(console, registry, renderable);
3993 }
3994
3995 let mut options = console.options();
3998 if let Some(max_width) = cli.max_width {
3999 options.max_width = max_width.min(options.max_width);
4000 }
4001 let shown = if cli.soft {
4002 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
4003 if !segments.is_empty() {
4004 segments.push(Segment::line());
4005 }
4006 emit_segments(console, export, segments)
4007 } else {
4008 emit(console, export, |c| {
4009 c.print_with(renderable.as_ref(), &options)
4010 })
4011 };
4012 finish(cli, shown)
4013}
4014
4015fn soft_wrap_segments(
4021 console: &Console,
4022 renderable: &dyn Renderable,
4023 options: &ConsoleOptions,
4024) -> Vec<Segment> {
4025 let mut options = options.clone();
4026 options.no_wrap.get_or_insert(true);
4027 options
4028 .overflow
4029 .get_or_insert(rich::console::Overflow::Ignore);
4030 let joined = renderable.printed_text();
4031 let renderable: &dyn Renderable = match &joined {
4032 Some(text) => text,
4033 None => renderable,
4034 };
4035 if renderable.fit_to_measurement() {
4036 let measurement = renderable.measure(console, &options);
4037 options.max_width = measurement.maximum.min(options.max_width).max(1);
4038 }
4039 console.render(renderable, Some(&options))
4040}
4041
4042fn run_cli(cli: Cli) -> ExitCode {
4043 if cli.watch {
4044 return run_watch(cli);
4045 }
4046 run_once(cli)
4047}
4048
4049fn run_watch(mut cli: Cli) -> ExitCode {
4050 let Some(resource) = cli.resource.clone() else {
4051 return fail(
4052 &cli,
4053 ExitClass::Usage,
4054 "--watch requires a file path or URL",
4055 );
4056 };
4057 let several = cli.resources.len() > 1;
4058 for resource in &cli.resources {
4059 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
4060 return fail(
4061 &cli,
4062 ExitClass::Usage,
4063 "--watch requires a local file or URL, not stdin or literal markup",
4064 );
4065 }
4066 if several && is_url(resource) {
4067 return fail(
4068 &cli,
4069 ExitClass::Usage,
4070 "--watch with several resources requires local files; watch a URL on its own",
4071 );
4072 }
4073 let effective_mode = if cli.mode == Mode::Auto {
4074 detect_mode(Some(resource))
4075 } else {
4076 cli.mode
4077 };
4078 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
4079 return fail(
4080 &cli,
4081 ExitClass::Usage,
4082 "--watch requires a resource-backed render mode",
4083 );
4084 }
4085 }
4086 if several
4087 && (cli.export_html.is_some() || cli.export_svg.is_some() || cli.export_png.is_some())
4088 {
4089 return fail(
4090 &cli,
4091 ExitClass::Usage,
4092 "--export-html/--export-svg/--export-png cannot be combined with watching several \
4093 files",
4094 );
4095 }
4096 if !std::io::stdout().is_terminal() {
4100 cli.watch = false;
4101 if !several {
4102 return run_once(cli);
4103 }
4104 let mut first_failure = None;
4105 for resource in cli.resources.clone() {
4106 let mut single = cli.clone();
4107 single.resource = Some(resource.clone());
4108 single.resources = vec![resource];
4109 let status = run_once(single);
4110 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
4111 first_failure = Some(status);
4112 }
4113 }
4114 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
4115 }
4116 if cfg!(target_os = "wasi") {
4118 return fail(&cli, ExitClass::Usage, host::unavailable("--watch"));
4119 }
4120 watch::start_watching();
4121 let resource = resource.as_str();
4122 if !is_url(resource) {
4123 return watch::watch_files(cli);
4124 }
4125 if is_url(resource) {
4127 #[cfg(not(feature = "fetch"))]
4128 {
4129 cli.watch = false;
4130 return run_once(cli);
4131 }
4132 }
4133
4134 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
4135 let mut previous = None;
4136 loop {
4137 #[cfg(feature = "fetch")]
4138 let cached = if is_url(resource) && cli.watch_cache {
4139 Some(fetch_url(resource, cli.extensions.encoding))
4140 } else {
4141 None
4142 };
4143 #[cfg(not(feature = "fetch"))]
4144 let cached: Option<Result<(String, Option<String>), String>> = None;
4145 let fingerprint = match cached.as_ref() {
4146 Some(Ok((body, content_type))) => {
4147 use std::hash::{Hash, Hasher};
4148 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4149 body.hash(&mut hasher);
4150 content_type.hash(&mut hasher);
4151 format!("url:{:x}", hasher.finish())
4152 }
4153 Some(Err(error)) => format!("url-error:{error}"),
4154 None => watch_fingerprint(resource, false, cli.extensions.encoding),
4155 };
4156 let changed = previous.as_ref() != Some(&fingerprint);
4157 if changed || (is_url(resource) && !cli.watch_cache) {
4158 previous = Some(fingerprint);
4159 let console = Console::new();
4162 console.clear();
4163 console.control(&Control::home());
4164 let _ = std::io::stdout().flush();
4165 let mut iteration = cli.clone();
4166 iteration.watch = false;
4167 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
4168 if status != ExitClass::Success.exit_code() {
4169 eprintln!("rich: watch will retry after the next change");
4172 }
4173 }
4174 std::thread::sleep(interval);
4175 }
4176}
4177
4178fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
4179 if is_url(resource) {
4180 #[cfg(feature = "fetch")]
4181 if cache_url {
4182 return match fetch_url(resource, encoding) {
4183 Ok((body, content_type)) => {
4184 use std::hash::{Hash, Hasher};
4185 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4186 body.hash(&mut hasher);
4187 content_type.hash(&mut hasher);
4188 format!("url:{:x}", hasher.finish())
4189 }
4190 Err(error) => format!("url-error:{error}"),
4191 };
4192 }
4193 #[cfg(not(feature = "fetch"))]
4194 let _ = (cache_url, encoding);
4195 return "url".to_string();
4196 }
4197 match std::fs::metadata(fs_path(resource)) {
4198 Ok(metadata) if metadata.is_file() => {
4199 use std::hash::Hasher;
4200 let mut file = match std::fs::File::open(fs_path(resource)) {
4204 Ok(file) => file,
4205 Err(error) => return format!("file-error:{error}"),
4206 };
4207 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4208 let mut buffer = [0u8; 64 * 1024];
4209 loop {
4210 match file.read(&mut buffer) {
4211 Ok(0) => break,
4212 Ok(count) => hasher.write(&buffer[..count]),
4213 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
4214 Err(error) => return format!("file-error:{error}"),
4215 }
4216 }
4217 format!("file:{:x}", hasher.finish())
4218 }
4219 Ok(_) => "not-file".to_string(),
4220 Err(_) => "missing".to_string(),
4221 }
4222}
4223
4224fn run_once(cli: Cli) -> ExitCode {
4225 run_once_with_fetch(cli, None)
4226}
4227
4228fn cli_theme(cli: &Cli) -> rich::Theme {
4231 let mut theme = rich::Theme::default_theme();
4232 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
4233 theme.insert(name.clone(), style.clone());
4234 }
4235 theme
4236}
4237
4238impl Cli {
4239 fn themed(&self) -> bool {
4241 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
4242 }
4243}
4244
4245fn read_theme_file(
4253 path: &str,
4254 label: &str,
4255) -> Result<std::collections::BTreeMap<String, Style>, String> {
4256 let limit = controls::THEME_FILE_LIMIT;
4257 let path = &fs_path(path);
4259 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
4261 if !metadata.is_file() {
4262 return Err(format!("{label}: not a regular file"));
4263 }
4264 let too_large = || {
4265 format!(
4266 "{label}: larger than {}, the most read from a theme file",
4267 controls::size(limit)
4268 )
4269 };
4270 if metadata.len() > limit {
4271 return Err(too_large());
4272 }
4273 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4274 let mut bytes = Vec::new();
4275 file.take(limit + 1)
4276 .read_to_end(&mut bytes)
4277 .map_err(|err| format!("{label}: {err}"))?;
4278 if bytes.len() as u64 > limit {
4279 return Err(too_large());
4280 }
4281 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4282 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4283 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4285 _ => match bad_style_line(&text) {
4286 Some(line) => format!("{label}: line {line}: {err}"),
4287 None => format!("{label}: {err}"),
4288 },
4289 })?;
4290 Ok(theme
4291 .names()
4292 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4293 .collect())
4294}
4295
4296fn bad_style_line(text: &str) -> Option<usize> {
4299 let mut in_styles = false;
4300 for (index, line) in text.lines().enumerate() {
4301 let trimmed = line.trim();
4302 if trimmed.starts_with('[') {
4303 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4304 continue;
4305 }
4306 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4307 continue;
4308 }
4309 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4310 continue;
4311 };
4312 if Style::parse(value.trim()).is_err() {
4313 return Some(index + 1);
4314 }
4315 }
4316 None
4317}
4318
4319fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4320 if cli.sanitize {
4321 cli.title = cli
4322 .title
4323 .take()
4324 .map(|title| sanitize_terminal_controls(&title));
4325 cli.caption = cli
4326 .caption
4327 .take()
4328 .map(|caption| sanitize_terminal_controls(&caption));
4329 }
4330
4331 if cli.batch {
4332 return run_batch(&cli);
4333 }
4334
4335 if cli.mode == Mode::Auto && cli.resource.is_none() {
4337 run_demo(cli.no_color, std::time::Duration::ZERO);
4338 return success(&cli);
4339 }
4340
4341 let mut mode = match cli.mode {
4342 Mode::Auto => detect_mode(cli.resource.as_deref()),
4343 other => other,
4344 };
4345 if let Some(message) = transform_misplaced_in(&cli, mode) {
4346 return fail(&cli, ExitClass::Usage, message);
4347 }
4348 if matches!(cli.mode, Mode::Auto | Mode::Man) {
4350 if let Some(message) = cli
4351 .resource
4352 .as_deref()
4353 .and_then(documents::compressed_man_page)
4354 {
4355 return fail(&cli, ExitClass::Input, message);
4356 }
4357 }
4358
4359 let mut prefetched = prefetched;
4363 let mut view_as = None;
4364 let mut view_bytes = Vec::new();
4365 if mode == Mode::View {
4366 let ext = cli
4367 .resource
4368 .as_deref()
4369 .filter(|r| *r != "-")
4370 .and_then(resource_ext);
4371 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4374 if !matches!(
4375 by_name,
4376 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4377 ) {
4378 let limit = if by_name == viewers::ViewAs::Notebook {
4381 controls::INSPECT_LIMIT
4382 } else {
4383 controls::VIEW_LIMIT
4384 };
4385 let more;
4386 (view_bytes, more) = match read_window(&cli, 0, limit) {
4387 Ok(read) => read,
4388 Err(err) => return fail(&cli, ExitClass::Input, err),
4389 };
4390 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4391 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4392 if kind == viewers::ViewAs::Binary {
4393 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4394 view_bytes.truncate(controls::BYTES_LIMIT as usize);
4395 viewers::notice(
4396 name,
4397 &controls::size(controls::BYTES_LIMIT),
4398 "use rich hex with --offset and --length for the rest",
4399 );
4400 }
4401 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4402 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4403 {
4404 view_bytes.truncate(end);
4406 viewers::notice(
4407 name,
4408 &format!("{} lines", controls::LINE_LIMIT),
4409 "use a pager or rich --syntax for the whole file",
4410 );
4411 } else if more {
4412 viewers::notice(
4413 name,
4414 &controls::size(limit),
4415 "use a pager or rich --syntax for the whole file",
4416 );
4417 }
4418 }
4419 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4420 mode = match kind {
4421 viewers::ViewAs::Markdown => Mode::Markdown,
4422 viewers::ViewAs::Csv => Mode::Csv,
4423 viewers::ViewAs::Notebook => Mode::Ipynb,
4424 viewers::ViewAs::Image => Mode::Image,
4425 viewers::ViewAs::Gif => Mode::Gif,
4426 viewers::ViewAs::Log => Mode::Log,
4427 _ => Mode::View,
4428 };
4429 let text = String::from_utf8_lossy(&view_bytes).into_owned();
4430 prefetched = Some((text, None));
4431 view_as = Some(kind);
4432 }
4433
4434 let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
4439 if cli.themed() {
4440 builder = builder.theme(cli_theme(&cli));
4441 }
4442 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4445 builder = builder.force_terminal(terminal == "1");
4446 }
4447 if cli.force_terminal {
4448 builder = builder.force_terminal(true);
4449 }
4450 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4451 builder = builder.width(width);
4452 }
4453 if let Some(width) = watch::capture_width() {
4456 builder = builder.width(width);
4457 }
4458 let mut console = builder.build();
4459 render_target::attach(&mut console);
4460 console.install_extensions();
4461 plugins::install_highlighters(&mut console);
4462 apply_code_highlighting(&mut console, &cli);
4463
4464 if mode == Mode::Rule {
4465 if let Some(title) = &cli.resource {
4466 if let Err(err) = Text::from_markup(title) {
4467 return fail(&cli, ExitClass::Data, err.to_string());
4468 }
4469 }
4470 }
4471
4472 let literal = mode == Mode::Rule
4478 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4479 let mut svg_title = cli
4480 .resource
4481 .as_deref()
4482 .filter(|r| *r != "-" && !literal)
4483 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4484 .unwrap_or_else(|| "rich".to_string());
4485 if cli.sanitize {
4486 svg_title = sanitize_terminal_controls(&svg_title);
4487 }
4488 #[allow(unused_mut)]
4489 let mut export = Export {
4490 html_path: cli.export_html.as_deref(),
4491 svg_path: cli.export_svg.as_deref(),
4492 png_path: cli.export_png.as_deref(),
4493 svg_title: &svg_title,
4494 pager: cli.pager,
4495 auto_pager: cli.auto_pager,
4496 };
4497
4498 if mode == Mode::Gif {
4501 return play_gifs(&cli, &console);
4502 }
4503
4504 if mode == Mode::Diff {
4507 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4508 if !images {
4509 return run_text_diff(&cli, &console, &export);
4510 }
4511 if let Some(flag) = cli.tools.diff_option() {
4512 return fail(
4513 &cli,
4514 ExitClass::Usage,
4515 format!("{flag} applies to text diffs, not images"),
4516 );
4517 }
4518 #[cfg(feature = "art")]
4519 return run_diff(&cli, &console, &export);
4520 #[cfg(not(feature = "art"))]
4521 return fail(
4522 &cli,
4523 ExitClass::Usage,
4524 "this build has no image support (rebuild with the `art` feature)",
4525 );
4526 }
4527
4528 #[cfg(feature = "art")]
4531 if mode == Mode::Image {
4532 return run_image(&cli, &console, &export);
4533 }
4534 #[cfg(not(feature = "art"))]
4535 if mode == Mode::Image {
4536 return fail(
4537 &cli,
4538 ExitClass::Usage,
4539 "this build has no image support (rebuild with the `art` feature)",
4540 );
4541 }
4542
4543 if mode.streams_records() {
4544 return run_json_lines(&cli, &console, mode == Mode::Log);
4545 }
4546
4547 if mode == Mode::Rule {
4551 let mut rule = match cli.resource.as_deref() {
4552 Some(title) if title != "-" && cli.sanitize => {
4553 Rule::new(sanitize_terminal_controls(title))
4554 }
4555 Some(title) if title != "-" => Rule::new(title),
4556 _ => Rule::line(),
4557 };
4558 let style = cli
4563 .rule_style
4564 .clone()
4565 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4566 rule = rule.style(style);
4567 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4568 if cli.sanitize {
4571 rule = rule.characters(sanitize_terminal_controls(characters));
4572 } else {
4573 rule = rule.characters(characters);
4574 }
4575 }
4576 match cli.text_justify {
4577 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4578 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4579 _ => {}
4580 }
4581 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4584 }
4585
4586 if mode == Mode::Capture {
4588 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4589 Ok(redactor) => redactor,
4590 Err(err) => return fail(&cli, ExitClass::Usage, err),
4591 };
4592 let preview = viewers::redact_preview(&cli.viewers);
4593 if preview && redactor.is_none() {
4594 return fail(
4595 &cli,
4596 ExitClass::Usage,
4597 "--redact-preview needs --redact or --redact-pattern",
4598 );
4599 }
4600 let width = cli
4603 .width
4604 .unwrap_or_else(|| console.width())
4605 .saturating_sub(4)
4606 .max(1);
4607 let cast = match viewers::cast_path(&cli.viewers).filter(|_| !preview) {
4610 Some(path) => match std::fs::File::create(path) {
4611 Ok(file) => Some((path, file)),
4612 Err(err) => {
4613 return fail(
4614 &cli,
4615 ExitClass::Input,
4616 format!("cannot write {path}: {err}"),
4617 )
4618 }
4619 },
4620 None => None,
4621 };
4622 let mut captured = match viewers::capture(&cli.resources, width) {
4623 Ok(captured) => captured,
4624 Err(err) => return fail(&cli, ExitClass::Input, err),
4625 };
4626 if let Some(notice) = &captured.notice {
4627 eprintln!("rich: capture: {notice}");
4628 }
4629 if let (true, Some(redactor)) = (preview, &redactor) {
4630 let would_write: Vec<String> = [
4631 viewers::cast_path(&cli.viewers),
4632 export.html_path,
4633 export.svg_path,
4634 export.png_path,
4635 ]
4636 .into_iter()
4637 .flatten()
4638 .map(str::to_string)
4639 .collect();
4640 let previews = redact_preview::capture(&captured, redactor);
4641 return redact_preview::emit(&cli, &console, previews, &would_write);
4642 }
4643 if let Some(redactor) = &redactor {
4645 captured.redact(redactor);
4646 }
4647 if cli.sanitize {
4648 captured.sanitize();
4649 }
4650 if let Some((path, mut file)) = cast {
4651 let cast = viewers::asciicast(&captured, width, console.height());
4652 if let Err(err) = file.write_all(cast.as_bytes()) {
4653 return fail(
4654 &cli,
4655 ExitClass::Input,
4656 format!("cannot write {path}: {err}"),
4657 );
4658 }
4659 }
4660 let view = viewers::capture_view(&captured);
4661 let fit = view.measure(&console, &console.options()).maximum;
4662 let code = captured.exit_code();
4665 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4666 if code == 0 || shown.is_err() {
4667 return emit_exit_code(cli, shown);
4668 }
4669 if cli.report_format == ReportFormat::Json {
4671 eprintln!("{}", viewers::capture_report(&captured));
4672 }
4673 ExitCode::from(code)
4674 });
4675 }
4676 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4677 return fail(
4678 &cli,
4679 ExitClass::Usage,
4680 "this build has no Mermaid support (the mermaid feature)",
4681 );
4682 }
4683 if mode == Mode::Env {
4684 let view = viewers::env(&cli.viewers, &cli.resources);
4685 let fit = view.measure(&console, &console.options()).maximum;
4686 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4687 }
4688 if mode == Mode::Profile {
4689 let profile = match profile::profile(&cli) {
4690 Ok(profile) => profile,
4691 Err((class, err)) => return fail(&cli, class, err),
4692 };
4693 if cli.report_format == ReportFormat::Json {
4695 let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4696 authoring::out(&format!("{json}\n"));
4697 return ExitClass::Success.exit_code();
4698 }
4699 let view: Box<dyn Renderable> = Box::new(profile);
4700 let fit = view.measure(&console, &console.options()).maximum;
4701 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4702 }
4703 if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4704 let view = match mode {
4705 Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4706 Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4707 _ => chart::chart(&cli).map(|view| (view, None)),
4708 };
4709 let (view, gate) = match view {
4710 Ok(view) => view,
4711 Err((class, err)) => return fail(&cli, class, err),
4712 };
4713 let fit = view.measure(&console, &console.options()).maximum;
4714 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4717 match gate {
4718 Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4719 _ => emit_exit_code(cli, shown),
4720 }
4721 });
4722 }
4723 if matches!(mode, Mode::Hex | Mode::Unicode) {
4724 let hex = mode == Mode::Hex;
4727 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4728 let length = cli.viewers.length().filter(|_| hex);
4729 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4730 let (bytes, more) = match read_window(&cli, offset, max) {
4731 Ok(read) => read,
4732 Err(err) => return fail(&cli, ExitClass::Input, err),
4733 };
4734 if more && length.is_none() {
4735 let hint = if hex {
4736 "use --offset and --length for the rest"
4737 } else {
4738 "pass a shorter input"
4739 };
4740 viewers::notice(
4741 cli.resource.as_deref().unwrap_or("<stdin>"),
4742 &controls::size(controls::BYTES_LIMIT),
4743 hint,
4744 );
4745 }
4746 let view = if hex {
4747 viewers::hex(&cli.viewers, bytes, offset)
4748 } else {
4749 viewers::unicode(&cli.viewers, &bytes)
4750 };
4751 let view = match view {
4752 Ok(view) => view,
4753 Err(err) => return fail(&cli, ExitClass::Usage, err),
4754 };
4755 let fit = view.measure(&console, &console.options()).maximum;
4756 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4757 }
4758
4759 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4763 let (mut content, content_type) = if let Some(fetched) = prefetched {
4764 fetched
4765 } else if resource_is_url {
4766 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4767 Ok(fetched) => fetched,
4768 Err(err) => {
4769 return fail(&cli, ExitClass::Input, err);
4770 }
4771 }
4772 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4773 (cli.resource.clone().unwrap(), None)
4774 } else {
4775 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4777 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4778 Ok(content) => (content, None),
4779 Err(err) => {
4780 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4781 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4782 }
4783 }
4784 };
4785 if cli.sanitize && mode != Mode::AnsiExplain {
4788 content = sanitize_terminal_controls(&content);
4789 }
4790
4791 let mut routed_language = None;
4794 let mut routed = false;
4795 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4796 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4797 routed = true;
4798 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4799 inspect::Route::Json => Mode::Json,
4800 inspect::Route::Syntax(lexer) => {
4801 routed_language = Some(lexer);
4802 Mode::Syntax
4803 }
4804 inspect::Route::Text => Mode::Auto,
4805 };
4806 }
4807 }
4808
4809 if cli.mode == Mode::Auto
4814 && !cli.source.wants_syntax()
4815 && (mode == Mode::Auto || (mode == Mode::Syntax && !routed))
4816 {
4817 let auto = cli.data.format == Some(inspect::InputFormat::Auto);
4818 if let Some(document) = documents::by_content(cli.resource.as_deref(), &content, auto) {
4819 mode = document;
4820 }
4821 }
4822
4823 if resource_is_url && mode == Mode::Auto && !routed {
4827 mode = content_type
4828 .as_deref()
4829 .and_then(content_type_mode)
4830 .unwrap_or(Mode::Syntax);
4831 }
4832
4833 if mode == Mode::Auto && cli.source.wants_syntax() {
4836 mode = Mode::Syntax;
4837 }
4838
4839 let json_content = if mode == Mode::Json && cli.sanitize {
4841 match sanitize_json_source(content.trim()) {
4842 Ok(content) => Some(content),
4843 Err(err) => {
4844 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4845 }
4846 }
4847 } else {
4848 None
4849 };
4850
4851 let json = if mode == Mode::Json {
4852 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4853 match Json::new(source) {
4854 Ok(json) => Some(json),
4855 Err(err) => {
4856 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4857 }
4858 }
4859 } else {
4860 None
4861 };
4862
4863 if mode == Mode::Ipynb {
4868 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4869 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4870 if cli.sanitize {
4871 sanitize_json_value(&mut value);
4872 }
4873 value
4874 }
4875 Ok(_) => {
4876 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4877 }
4878 Err(err) => {
4879 return fail(
4880 &cli,
4881 ExitClass::Data,
4882 format!("invalid notebook JSON: {err}"),
4883 );
4884 }
4885 };
4886 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4887 Ok(renderable) => renderable,
4888 Err(err) => return fail(&cli, ExitClass::Usage, err),
4889 };
4890 let fit = renderable.measure(&console, &console.options()).maximum;
4891 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4892 }
4893
4894 if mode == Mode::Print {
4898 if let Err(err) = console.try_build_text(&content) {
4899 return fail(&cli, ExitClass::Data, err.to_string());
4900 }
4901 }
4902
4903 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4908 .filter(|ext| !is_uninformative_ext(ext));
4909 let language = routed_language
4910 .map(str::to_string)
4911 .or(extension)
4912 .or_else(|| {
4913 content_type
4914 .as_deref()
4915 .and_then(content_type_lexer)
4916 .map(str::to_string)
4917 })
4918 .unwrap_or_default();
4919
4920 if let Some(message) = transform_misplaced_in(&cli, mode) {
4922 return fail(&cli, ExitClass::Usage, message);
4923 }
4924
4925 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4928 Mode::Markdown => {
4931 #[cfg(feature = "art")]
4933 let micro = micro::markdown(&console, &content, cli.micro_project);
4934 #[cfg(feature = "art")]
4935 let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
4936 #[cfg(not(feature = "art"))]
4937 let source = content.as_str();
4938 let mut markdown = build_markdown(source, cli.hyperlinks);
4939 if let Some(fences) = plugin_registry_with(
4942 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4943 cli.dot_backend != Some(sources::DotBackend::Off),
4944 )
4945 .fences()
4946 {
4947 markdown = markdown.fence_renderer(fences);
4948 }
4949 #[cfg(feature = "art")]
4950 let view: Box<dyn Renderable> = match micro {
4951 Some(micro) => Box::new(micro.view(markdown)),
4952 None => Box::new(markdown),
4953 };
4954 #[cfg(not(feature = "art"))]
4955 let view: Box<dyn Renderable> = Box::new(markdown);
4956 (view, None)
4957 }
4958 Mode::Rst => {
4962 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4963 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4964 (Box::new(document), None)
4965 }
4966 Mode::Org | Mode::AsciiDoc | Mode::Man => {
4969 let fences = plugin_registry_with(
4970 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4971 cli.dot_backend != Some(sources::DotBackend::Off),
4972 )
4973 .fences();
4974 (documents::render(mode, &content, &cli, fences), None)
4975 }
4976 #[cfg(feature = "mermaid")]
4977 Mode::Mermaid => {
4978 let default = if cfg!(feature = "mmdc") {
4980 MermaidBackend::Mmdc
4981 } else {
4982 MermaidBackend::Text
4983 };
4984 let backend = match cli.mermaid_backend.unwrap_or(default) {
4985 MermaidBackend::Off => MermaidBackend::Text,
4986 chosen => chosen,
4987 };
4988 let diagram =
4989 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4990 (Box::new(diagram), None)
4991 }
4992 Mode::Dot => {
4993 let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
4996 let mut svg_done = false;
4997 if let (true, Some(path)) = (graphviz, export.svg_path) {
4998 svg_done = sources::graphviz_svg(&content, path);
4999 if svg_done {
5000 export.svg_path = None;
5001 }
5002 }
5003 match sources::dot(&content, cli.resource.as_deref()) {
5004 Ok(diagram) => (diagram, None),
5005 Err((_, err)) if svg_done => {
5008 eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
5009 return success(&cli);
5010 }
5011 Err((class, err)) => return fail(&cli, class, err),
5012 }
5013 }
5014 Mode::Json => {
5015 let nested = cli.padding.is_some()
5032 || cli.panel.is_some()
5033 || cli.style.is_some()
5034 || cli.width.is_some()
5035 || cli.pager
5036 || cli.auto_pager;
5037 let json = json.expect("json parsed above").no_wrap(nested);
5038 #[cfg(feature = "json-escape-safe")]
5039 let json = json.escape_safe(true);
5040 let fit = measure_rendered(&console, &json);
5041 (Box::new(json), Some(fit))
5042 }
5043 Mode::Csv => {
5044 if let Err(err) = validate_labels(&cli) {
5045 return fail(&cli, ExitClass::Data, err);
5046 }
5047 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
5050 let rules = cli.rules.rules();
5051 let table = match build_csv_table_with(
5052 &content,
5053 fallback,
5054 cli.title.as_deref(),
5055 cli.caption.as_deref(),
5056 (cli.source.head, cli.source.tail),
5057 cli.source.infer,
5058 (!rules.is_empty()).then_some((&console, rules)),
5059 ) {
5060 Some(table) => table,
5061 None => {
5064 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
5065 }
5066 };
5067 #[cfg(feature = "art")]
5070 let table = {
5071 let mut table = table;
5072 let label = |label: &Option<String>, style| {
5073 label.as_deref().and_then(|label| {
5074 micro::table_label(&console, label, style, cli.micro_project)
5075 })
5076 };
5077 if let Some(title) = label(&cli.title, "table.title") {
5078 table = table.title_text(title);
5079 }
5080 if let Some(caption) = label(&cli.caption, "table.caption") {
5081 table = table.caption_text(caption);
5082 }
5083 table
5084 };
5085 if cli.panel.is_none()
5089 && cli.padding.is_none()
5090 && cli.style.is_none()
5091 && cli.justify.is_none()
5092 && cli.max_width.is_none()
5093 && !cli.soft
5094 && !cli.pager
5095 && !cli.auto_pager
5096 && cli.export_html.is_none()
5097 && cli.export_svg.is_none()
5098 && cli.export_png.is_none()
5099 && !watch::capturing()
5100 && !micro_pending()
5101 {
5102 let mut options = console.options();
5103 options.max_width = cli.width.unwrap_or_else(|| {
5104 table
5105 .measure(&console, &options)
5106 .maximum
5107 .min(options.max_width)
5108 .max(1)
5109 });
5110 let stdout = std::io::stdout();
5111 let mut output = std::io::BufWriter::new(stdout.lock());
5112 let result = table
5113 .try_for_each_line(&console, &options, |line| {
5114 let line = Segment::crop_lines(&line, console.width());
5115 writeln!(output, "{}", console.segments_to_string(&line))
5116 })
5117 .and_then(|()| output.flush());
5118 if let Err(error) = result {
5119 if error.kind() == std::io::ErrorKind::BrokenPipe {
5122 return success(&cli);
5123 }
5124 return fail(
5125 &cli,
5126 ExitClass::Input,
5127 format!("could not write CSV output: {error}"),
5128 );
5129 }
5130 return success(&cli);
5131 }
5132 let fit = measure_rendered(&console, &table);
5133 (Box::new(table), Some(fit))
5134 }
5135 Mode::Syntax if cli.data.transform_option().is_some() => {
5138 let language = cli.source.lexer.as_deref().unwrap_or(&language);
5139 let code = match cli.source.line_range(&content) {
5142 Ok(Some((start, end))) => {
5143 let start = start.max(1) as usize - 1;
5144 let end = end.max(0) as usize;
5145 let lines: Vec<&str> = content.lines().collect();
5146 lines
5147 .get(start.min(lines.len())..end.min(lines.len()))
5148 .unwrap_or_default()
5149 .join("\n")
5150 }
5151 Ok(None) => content.clone(),
5152 Err(err) => return fail(&cli, ExitClass::Usage, err),
5153 };
5154 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
5155 let text = match cli
5156 .data
5157 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5158 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5159 {
5160 Ok(text) => text,
5161 Err(err) => return fail(&cli, ExitClass::Data, err),
5162 };
5163 let fit = text.measurement().1;
5164 let background = match text.base_style() {
5167 rich::style::StyleType::Style(style) => style.clone(),
5168 rich::style::StyleType::Name(_) => rich::Style::new(),
5169 };
5170 let padded =
5173 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
5174 (Box::new(padded), Some(fit))
5175 }
5176 Mode::Syntax => {
5177 let syntax = match cli.source.syntax(&content, &language) {
5178 Ok(syntax) => syntax,
5179 Err(err) => return fail(&cli, ExitClass::Usage, err),
5180 };
5181 let fit = syntax.measure(&console, &console.options()).maximum;
5184 (Box::new(syntax), Some(fit))
5185 }
5186 Mode::View => {
5187 let view: Box<dyn Renderable> = match view_as {
5188 Some(viewers::ViewAs::Patch) => {
5189 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
5190 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
5191 Ok(outcome) => outcome.renderable,
5192 Err(err) => return fail(&cli, ExitClass::Data, err),
5193 }
5194 }
5195 Some(viewers::ViewAs::Binary) => {
5196 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
5197 Ok(view) => view,
5198 Err(err) => return fail(&cli, ExitClass::Usage, err),
5199 }
5200 }
5201 Some(viewers::ViewAs::Source(ref lexer)) => {
5202 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
5203 eprintln!("{summary}");
5204 }
5205 viewers::source(&cli.viewers, &content, lexer)
5206 }
5207 _ => viewers::source(&cli.viewers, &content, "txt"),
5208 };
5209 let fit = view.measure(&console, &console.options()).maximum;
5210 (view, Some(fit))
5211 }
5212 Mode::AnsiExplain => {
5213 let view = tools::ansi_explain(&cli.tools, &content);
5214 let fit = view.measure(&console, &console.options()).maximum;
5215 (view, Some(fit))
5216 }
5217 Mode::Inspect => {
5218 let encoding = cli.extensions.encoding;
5219 let read = |path: &str| {
5220 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
5221 .map_err(|err| format!("cannot read {path}: {err}"))
5222 };
5223 let rules = cli.rules.rules();
5224 let view = match inspect::build_with(
5225 &cli.data,
5226 &content,
5227 cli.resource.as_deref(),
5228 read,
5229 rules,
5230 ) {
5231 Ok(view) => view,
5232 Err(err) => return fail(&cli, ExitClass::Data, err),
5233 };
5234 let fit = view.measure(&console, &console.options()).maximum;
5235 (view, Some(fit))
5236 }
5237 _ => {
5240 #[cfg(feature = "art")]
5243 let micro = (mode == Mode::Print && cli.emoji)
5244 .then(|| micro::print_markup(&console, &content, cli.micro_project))
5245 .flatten();
5246 #[cfg(feature = "art")]
5247 let mut text = match micro {
5248 Some(text) => text,
5249 None if mode == Mode::Print => console.build_text(&content),
5250 None => Text::new(content.as_str()),
5251 };
5252 #[cfg(not(feature = "art"))]
5253 let mut text = if mode == Mode::Print {
5254 console.build_text(&content)
5255 } else {
5256 Text::new(content.as_str())
5257 };
5258 if let Some(justify) = cli.text_justify {
5262 text.set_justify(justify);
5263 }
5264 if cli.source.no_wrap {
5265 text.set_no_wrap(Some(true));
5266 }
5267 let text = match cli
5268 .data
5269 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5270 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5271 {
5272 Ok(text) => text,
5273 Err(err) => return fail(&cli, ExitClass::Data, err),
5274 };
5275 let fit = text.measurement().1;
5276 #[cfg(feature = "art")]
5278 let renderable = micro::rows(text);
5279 #[cfg(not(feature = "art"))]
5280 let renderable: Box<dyn Renderable> = Box::new(text);
5281 (renderable, Some(fit))
5282 }
5283 };
5284 decorate_and_emit(&cli, &console, &export, renderable, fit)
5285}
5286
5287#[allow(dead_code)]
5290fn exit_code(ok: bool) -> ExitCode {
5291 if ok {
5292 ExitCode::SUCCESS
5293 } else {
5294 ExitCode::FAILURE
5295 }
5296}
5297
5298fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
5299 match result {
5300 Ok(()) => success(cli),
5301 Err(message) => fail(cli, ExitClass::Input, message),
5302 }
5303}
5304
5305fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
5306 let output = console.render_export(renderable);
5307 let stdout = std::io::stdout();
5308 let mut lock = stdout.lock();
5309 lock.write_all(output.as_bytes())
5310}
5311
5312fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
5313 if cli.extensions.encoding.is_some() {
5314 return fail(
5315 cli,
5316 ExitClass::Usage,
5317 "--encoding is not supported with streaming JSONL/log mode",
5318 );
5319 }
5320 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
5321 return fail(
5322 cli,
5323 ExitClass::Usage,
5324 "streaming JSONL/log mode reads files or stdin, not URLs",
5325 );
5326 }
5327 if log_mode {
5328 return logs::run(cli, console);
5329 }
5330
5331 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
5332 Some(path) if path != "-" => {
5333 let file = &fs_path(path);
5334 if file.is_dir() {
5335 return fail(
5336 cli,
5337 ExitClass::Input,
5338 format!("cannot read {path}: is a directory, not a file"),
5339 );
5340 }
5341 match std::fs::File::open(file) {
5342 Ok(file) => Box::new(std::io::BufReader::new(file)),
5343 Err(err) => {
5344 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5345 }
5346 }
5347 }
5348 _ => {
5349 if std::io::stdin().is_terminal() {
5350 let eof = if cfg!(windows) {
5351 "Ctrl-Z then Enter"
5352 } else {
5353 "Ctrl-D"
5354 };
5355 eprintln!("rich: reading stdin; finish input with {eof}");
5356 }
5357 Box::new(std::io::BufReader::new(std::io::stdin()))
5358 }
5359 };
5360
5361 let mut line = String::new();
5362 let mut line_number = 0usize;
5363 loop {
5364 line.clear();
5365 let read = match input.read_line(&mut line) {
5366 Ok(read) => read,
5367 Err(err) => {
5368 return fail(
5369 cli,
5370 ExitClass::Input,
5371 format!("cannot read JSONL stream: {err}"),
5372 );
5373 }
5374 };
5375 if read == 0 {
5376 break;
5377 }
5378 line_number += 1;
5379 let record = line.trim_end_matches(['\r', '\n']);
5380 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5381 Ok(value) => value,
5382 Err(err) => {
5383 return fail(
5384 cli,
5385 ExitClass::Data,
5386 format!("invalid JSONL at line {line_number}: {err}"),
5387 );
5388 }
5389 };
5390 if cli.sanitize {
5391 sanitize_json_value(&mut value);
5392 }
5393 let source = serde_json::to_string(&value).expect("JSON value serializes");
5394 let json = Json::new(&source).expect("serialized JSON parses");
5395 let wrote = write_rendered_stdout(console, &json);
5396 if let Err(error) = wrote {
5397 if error.kind() == std::io::ErrorKind::BrokenPipe {
5398 return success(cli);
5399 }
5400 return fail(
5401 cli,
5402 ExitClass::Input,
5403 format!("could not write JSONL output: {error}"),
5404 );
5405 }
5406 }
5407 success(cli)
5408}
5409
5410fn is_number(value: &str) -> bool {
5416 let body = value.strip_prefix('-').unwrap_or(value);
5417 let (integer, fraction) = match body.split_once('.') {
5418 Some((integer, fraction)) => (integer, Some(fraction)),
5419 None => (body, None),
5420 };
5421 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5422 digits(integer) && fraction.is_none_or(digits)
5423}
5424
5425fn build_csv_table(
5444 content: &str,
5445 fallback_delimiter: Option<char>,
5446 title: Option<&str>,
5447 caption: Option<&str>,
5448 window: (Option<usize>, Option<usize>),
5449 infer: bool,
5450) -> Option<Table> {
5451 build_csv_table_with(
5452 content,
5453 fallback_delimiter,
5454 title,
5455 caption,
5456 window,
5457 infer,
5458 None,
5459 )
5460}
5461
5462fn build_csv_table_with(
5465 content: &str,
5466 fallback_delimiter: Option<char>,
5467 title: Option<&str>,
5468 caption: Option<&str>,
5469 window: (Option<usize>, Option<usize>),
5470 infer: bool,
5471 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5472) -> Option<Table> {
5473 let sample: String = content.chars().take(1024).collect();
5476 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5477 let (dialect, header) = match sniffed {
5478 Some(sniffed) => sniffed,
5479 None => (Dialect::excel(fallback_delimiter?), true),
5480 };
5481 let mut rows = read_rows(content, &dialect);
5482 if window.0.is_some() || window.1.is_some() {
5485 let header_row = if header && !rows.is_empty() {
5486 Some(rows.remove(0))
5487 } else {
5488 None
5489 };
5490 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5491 match window {
5492 (Some(head), _) => data.truncate(head),
5493 (None, Some(tail)) => {
5494 data.drain(..data.len().saturating_sub(tail));
5495 }
5496 (None, None) => {}
5497 }
5498 rows = header_row.into_iter().chain(data).collect();
5499 }
5500 let types = infer.then(|| infer_csv_types(&rows, header));
5501 Some(render_csv(rows, header, title, caption, types, rules))
5502}
5503
5504fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5508 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5509 let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5510 for row in rows
5511 .iter()
5512 .skip(usize::from(has_header))
5513 .filter(|row| !row.is_empty())
5514 {
5515 data.push(
5516 (0..width)
5517 .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5518 );
5519 }
5520 Inferrer::new()
5521 .infer(&data)
5522 .columns()
5523 .iter()
5524 .map(|column| column.data_type())
5525 .collect()
5526}
5527
5528fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5535 let name = resource.unwrap_or_default().to_lowercase();
5536 if name.ends_with(".csv") {
5537 Some(',')
5538 } else if name.ends_with(".tsv") {
5539 Some('\t')
5540 } else {
5541 None
5542 }
5543}
5544
5545fn render_csv(
5559 rows: Vec<Vec<String>>,
5560 has_header: bool,
5561 title: Option<&str>,
5562 caption: Option<&str>,
5563 types: Option<Vec<InferredType>>,
5564 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5565) -> Table {
5566 let mut table = Table::new()
5567 .border_style(Style::parse("blue").expect("valid style"))
5568 .show_header(has_header || types.is_some())
5569 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5570 if let Some(title) = title {
5571 table = table.title(title);
5572 }
5573 if let Some(caption) = caption {
5574 table = table.caption(caption);
5575 }
5576
5577 let empty: Vec<String> = Vec::new();
5578 let (header, body) = if has_header {
5579 match rows.split_first() {
5580 Some((header, body)) => (header, body),
5581 None => return table,
5582 }
5583 } else {
5584 (&empty, rows.as_slice())
5585 };
5586 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5588
5589 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5594 let columns = header.len().max(widest);
5595
5596 for index in 0..columns {
5597 let inferred = types
5602 .as_ref()
5603 .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5604 let numeric = match inferred {
5605 Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5606 None => data.iter().all(|row| match row.get(index) {
5607 Some(value) => value.is_empty() || is_number(value),
5608 None => false,
5609 }),
5610 };
5611 let name = header.get(index).map(String::as_str).unwrap_or("");
5612 if let Some(inferred) = inferred {
5613 let mut heading = Text::new(name);
5614 if !name.is_empty() {
5615 heading.append("\n", None);
5616 }
5617 heading.append(
5618 inferred.name(),
5619 Some(Style::parse("dim").expect("valid style").into()),
5620 );
5621 let justify = if numeric {
5622 Justify::Right
5623 } else {
5624 Justify::Left
5625 };
5626 table.add_column_text(heading, justify);
5627 } else if numeric {
5628 table.add_column_justify(name, Justify::Right);
5629 } else {
5630 table.add_column(name);
5631 }
5632 if numeric {
5633 table.column_style(Style::parse("bold green").expect("valid style"));
5634 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5635 }
5636 }
5637 drop(data);
5640 let headers: Vec<String> = match rules {
5641 Some(_) => (0..columns)
5642 .map(|index| header.get(index).cloned().unwrap_or_default())
5643 .collect(),
5644 None => Vec::new(),
5645 };
5646 let body = rows
5647 .into_iter()
5648 .skip(usize::from(has_header))
5649 .filter(|row| !row.is_empty());
5650 match rules.filter(|(_, rules)| !rules.is_empty()) {
5651 None => {
5652 table.extend_owned_rows(body.collect());
5653 }
5654 Some((console, rules)) => {
5655 add_styled_csv_rows(&mut table, console, rules, &headers, body.collect())
5656 }
5657 }
5658 table
5659}
5660
5661fn add_styled_csv_rows(
5665 table: &mut Table,
5666 console: &Console,
5667 rules: &rich_ext::table::StyleRules,
5668 headers: &[String],
5669 rows: Vec<Vec<String>>,
5670) {
5671 use rich_ext::table::{Target, Value};
5672 let headers: Vec<&str> = headers.iter().map(String::as_str).collect();
5673 let values = |row: &[String]| -> Vec<Value> { row.iter().cloned().map(Value::Str).collect() };
5674 let resolved = if rules
5676 .rules()
5677 .iter()
5678 .any(|r| r.target_kind() == Target::Column)
5679 {
5680 rules.resolve(&headers, rows.iter().map(|row| values(row)))
5681 } else {
5682 rules.resolve(&headers, std::iter::empty::<Vec<Value>>())
5683 };
5684 style_rules::warn_unresolved(&resolved, &headers);
5685 for row in rows {
5686 let cells: Vec<Value> = row.into_iter().map(Value::Str).collect();
5687 let row_style = resolved.row_style(&cells);
5688 let styles: Vec<Option<Style>> = (0..cells.len())
5689 .map(|index| resolved.cell_style(&cells, index))
5690 .collect();
5691 let cells = cells
5692 .into_iter()
5693 .zip(styles)
5694 .map(|(cell, style)| {
5695 let markup = match cell {
5696 Value::Str(text) => text,
5697 other => other.plain(),
5698 };
5699 match style {
5700 None => rich::Cell::Markup(markup),
5701 Some(style) => {
5702 let mut text = console.render_str(&markup, Some(false));
5703 let len = text.plain().len();
5704 text.stylize(style, 0, len);
5705 rich::Cell::Text(text)
5706 }
5707 }
5708 })
5709 .collect();
5710 table.add_row_with(cells, row_style.map(Into::into), false);
5711 }
5712}
5713
5714fn join_source(value: &serde_json::Value) -> String {
5717 match value {
5718 serde_json::Value::String(s) => s.clone(),
5719 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5720 _ => String::new(),
5721 }
5722}
5723
5724fn join_traceback(value: &serde_json::Value) -> String {
5728 match value {
5729 serde_json::Value::String(s) => s.clone(),
5730 serde_json::Value::Array(a) => a
5731 .iter()
5732 .filter_map(|v| v.as_str())
5733 .collect::<Vec<_>>()
5734 .join(
5735 "
5736",
5737 ),
5738 _ => String::new(),
5739 }
5740}
5741
5742fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5746 let base_style = Style::parse(base).ok();
5747 let number_style = Style::parse(number).ok();
5748 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5749 let mut text = Text::new("");
5750 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5751 text.append(&n, number_style.map(Into::into));
5752 text.append("]:", base_style.map(Into::into));
5753 text
5754}
5755
5756struct Notebook {
5759 items: Vec<Box<dyn Renderable>>,
5760 justify: Option<Justify>,
5763}
5764
5765impl Renderable for Notebook {
5766 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5767 let mut options = options.clone();
5768 if let Some(justify) = self.justify {
5769 options.justify = justify;
5770 }
5771 let mut segments = Vec::new();
5772 for item in &self.items {
5773 let rendered = item.rich_render(console, &options);
5774 if !rendered.is_empty() {
5775 segments.extend(rendered);
5776 segments.push(Segment::line());
5777 }
5778 }
5779 if !segments.is_empty() {
5781 segments.pop();
5782 }
5783 segments
5784 }
5785
5786 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5787 self.items
5788 .iter()
5789 .map(|item| item.measure(console, options))
5790 .fold(Measurement::new(0, 0), |width, item| {
5791 Measurement::new(
5792 width.minimum.max(item.minimum),
5793 width.maximum.max(item.maximum),
5794 )
5795 })
5796 }
5797}
5798
5799fn notebook_ansi(source: &str) -> Text {
5801 let mut result = Text::new("");
5802 let mut decoder = AnsiDecoder::new();
5805 let lines: Vec<Text> = source
5806 .lines()
5807 .map(|line| decoder.decode_line(line))
5808 .collect();
5809 for (index, line) in lines.iter().enumerate() {
5810 if index > 0 {
5811 result.append("\n", None);
5812 }
5813 result = result.append_text(line);
5814 }
5815 result
5816}
5817
5818fn build_ipynb(
5821 notebook: &serde_json::Value,
5822 hyperlinks: bool,
5823 justify: Option<Justify>,
5824 source_options: &SourceOptions,
5825) -> Result<Notebook, String> {
5826 let language = notebook["metadata"]["kernelspec"]["language"]
5827 .as_str()
5828 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5829 .unwrap_or("python");
5830 let empty = Vec::new();
5831 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5832 let mut new_line = true;
5833 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5834 if new_line {
5835 items.push(text(""));
5836 }
5837 if cell.get("execution_count").is_some() {
5838 items.push(Box::new(io_label(
5839 "In ",
5840 cell["execution_count"].as_i64().filter(|n| *n != 0),
5841 "green",
5842 "#66ff00",
5843 )));
5844 }
5845 let source = join_source(&cell["source"]);
5846 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5847 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5848 "code" => Box::new(
5851 Panel::new(Box::new(source_options.syntax(&source, language)?))
5852 .border_style(Style::parse("dim").expect("valid style")),
5853 ),
5854 _ => text(&source),
5855 });
5856 new_line = true;
5857 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5858 let rendered = match output["output_type"].as_str().unwrap_or("") {
5859 "stream" => {
5860 new_line = false;
5861 notebook_ansi(&join_source(&output["text"]))
5862 }
5863 "error" => {
5864 new_line = true;
5865 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5866 }
5867 "execute_result" => {
5868 new_line = true;
5869 let mut label = io_label(
5870 "Out",
5871 output["execution_count"].as_i64().filter(|n| *n != 0),
5872 "red",
5873 "#ee4b2b",
5874 );
5875 label.append("\n", None);
5876 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5877 }
5878 _ => continue,
5879 };
5880 items.push(Box::new(rendered));
5881 }
5882 }
5883 Ok(Notebook { items, justify })
5884}
5885
5886fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5887 let displayed = |value: f32| {
5890 format!("{value:.1}")
5891 .parse::<f32>()
5892 .expect("formatted percentage is a number")
5893 };
5894 displayed(changed) > displayed(limit)
5895}
5896
5897fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5900 let mut contents = Vec::with_capacity(cli.resources.len());
5901 for resource in &cli.resources {
5902 let content = if is_url(resource) {
5903 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5904 } else {
5905 read_resource(Some(resource), cli.extensions.encoding)
5906 .map_err(|err| format!("cannot read {resource}: {err}"))
5907 };
5908 match content {
5909 Ok(content) => contents.push(content),
5910 Err(err) => return fail(cli, ExitClass::Input, err),
5911 }
5912 }
5913 let names: Vec<String> = cli
5914 .resources
5915 .iter()
5916 .map(|r| {
5917 if r == "-" {
5918 "<stdin>".to_string()
5919 } else {
5920 r.clone()
5921 }
5922 })
5923 .collect();
5924 let outcome = match tools::text_diff(
5925 &cli.tools,
5926 &names,
5927 &contents,
5928 cli.diff_threshold,
5929 cli.sanitize,
5930 ) {
5931 Ok(outcome) => outcome,
5932 Err(err) => return fail(cli, ExitClass::Data, err),
5933 };
5934 let fit = outcome
5935 .renderable
5936 .measure(console, &console.options())
5937 .maximum;
5938 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5939 if code != ExitCode::SUCCESS || !outcome.failed {
5940 return code;
5941 }
5942 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5943}
5944
5945#[cfg(feature = "art")]
5961fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5962 if fs_path(path).is_dir() {
5963 return Err(format!("cannot read {path}: is a directory, not a file"));
5964 }
5965 match rich_art::image::open(fs_path(path)) {
5966 Ok(image) => Ok(image),
5967 Err(err) => {
5968 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5969 let hint = match &err {
5970 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5971 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5972 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5973 _ => None,
5974 },
5975 _ => None,
5976 };
5977 Err(format!(
5978 "cannot read {path}: {}",
5979 hint.unwrap_or_else(|| err.to_string())
5980 ))
5981 }
5982 }
5983}
5984
5985#[cfg(feature = "art")]
5991fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5992 match mode {
5993 ImageMode::Auto => rich_art::ImageMode::Auto,
5994 ImageMode::Sixel => rich_art::ImageMode::Sixel,
5995 ImageMode::Blocks => rich_art::ImageMode::Blocks,
5996 ImageMode::Braille => rich_art::ImageMode::Braille,
5997 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5998 ImageMode::Ascii => rich_art::ImageMode::Ascii,
5999 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
6000 }
6001}
6002
6003#[cfg(feature = "art")]
6010fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6011 use rich_art::ImageArt;
6012
6013 let Some(resource) = cli.resource.as_deref() else {
6014 return fail(
6015 cli,
6016 ExitClass::Usage,
6017 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
6018 );
6019 };
6020
6021 let image = if resource == "-" {
6022 let mut buffer = Vec::new();
6023 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
6024 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
6025 }
6026 match rich_art::image::load_from_memory(&buffer) {
6027 Ok(image) => image,
6028 Err(err) => {
6029 return fail(
6030 cli,
6031 ExitClass::Input,
6032 format!("cannot decode image from stdin: {err}"),
6033 )
6034 }
6035 }
6036 } else {
6037 match open_image_path(resource) {
6038 Ok(image) => image,
6039 Err(message) => return fail(cli, ExitClass::Input, message),
6040 }
6041 };
6042
6043 let width = cli.width.unwrap_or_else(|| console.width());
6044 let mut art = ImageArt::new(image)
6045 .transforms(rich_art::ImageTransforms {
6046 rotation: match cli.image_rotate {
6047 Some(90) => rich_art::Rotation::Clockwise90,
6048 Some(180) => rich_art::Rotation::Clockwise180,
6049 Some(270) => rich_art::Rotation::Clockwise270,
6050 _ => rich_art::Rotation::None,
6051 },
6052 flip_horizontal: cli.image_flip_horizontal,
6053 flip_vertical: cli.image_flip_vertical,
6054 grayscale: cli.image_grayscale,
6055 brightness: cli.image_brightness.unwrap_or(1.0),
6056 contrast: cli.image_contrast.unwrap_or(1.0),
6057 gamma: cli.image_gamma.unwrap_or(1.0),
6058 })
6059 .mode(to_art_image_mode(cli.image_mode))
6060 .width(width)
6061 .color(!cli.no_color);
6062 if let Some(height) = cli.height {
6063 art = art.height(height);
6064 }
6065 if let Some(columns) = cli.image_max_width {
6066 art = art.max_width(columns);
6067 }
6068 if let Some(rows) = cli.image_max_height {
6069 art = art.max_height(rows);
6070 }
6071
6072 if let Some(fit) = cli.image_fit.as_deref() {
6073 art = art.fit(match fit {
6074 "cover" => rich_art::ImageFit::Cover,
6075 "stretch" => rich_art::ImageFit::Stretch,
6076 "native" => rich_art::ImageFit::Native,
6077 _ => rich_art::ImageFit::Contain,
6078 });
6079 }
6080 if let Some(anchor) = cli.image_anchor.as_deref() {
6081 use rich_art::ImageAnchor;
6082 art = art.anchor(match anchor {
6083 "top" => ImageAnchor::Top,
6084 "bottom" => ImageAnchor::Bottom,
6085 "left" => ImageAnchor::Left,
6086 "right" => ImageAnchor::Right,
6087 "top-left" => ImageAnchor::TopLeft,
6088 "top-right" => ImageAnchor::TopRight,
6089 "bottom-left" => ImageAnchor::BottomLeft,
6090 "bottom-right" => ImageAnchor::BottomRight,
6091 _ => ImageAnchor::Center,
6092 });
6093 }
6094 match cli.image_background {
6095 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
6096 Some(ImageBackdrop::Terminal) => {
6097 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
6098 }
6099 Some(ImageBackdrop::Checkerboard) => {
6100 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
6101 }
6102 None => {}
6103 }
6104 let (color_mode, dither, distance) = image_color_processing(cli);
6105 art = art
6106 .color_mode(color_mode)
6107 .dither(dither)
6108 .color_distance(distance);
6109
6110 let options = console.options();
6115 if let Err(err) = art.render(console, &options) {
6116 let message = match err {
6120 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
6121 "this terminal (TERM={}) is not known to support Sixel graphics; set \
6122 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
6123 --image-mode blocks, braille or ascii",
6124 std::env::var("TERM").unwrap_or_default()
6125 ),
6126 err => err.to_string(),
6127 };
6128 return fail(cli, ExitClass::Input, message);
6129 }
6130
6131 let terminal_only = Export {
6132 html_path: None,
6133 svg_path: None,
6134 png_path: None,
6135 ..*export
6136 };
6137 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
6138 return fail(cli, ExitClass::Input, message);
6139 }
6140 if export.any_file() {
6141 use rich::protocol::RenderEnvironment;
6142 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
6143 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
6144 caps.color_system = if cli.no_color {
6145 None
6146 } else {
6147 Some(ColorSystem::Truecolor)
6148 };
6149 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
6150 let export_console = target.console();
6151 if let Err(err) =
6152 art.render_with_environment(&export_console, &export_console.options(), &target)
6153 {
6154 return fail(cli, ExitClass::Input, err.to_string());
6155 }
6156 let segments = target.segments(&art);
6157 debug_assert!(!target.capabilities().interactive);
6158 if let Err(message) = save_exports(&export_console, export, &segments) {
6159 return fail(cli, ExitClass::Input, message);
6160 }
6161 }
6162 success(cli)
6163}
6164
6165#[cfg(feature = "art")]
6166fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6167 use rich::Table;
6168 use rich_art::imagediff::{diff, DiffSettings};
6169 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
6170
6171 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
6172 let before = match open_image_path(before_path) {
6173 Ok(image) => image,
6174 Err(message) => return fail(cli, ExitClass::Input, message),
6175 };
6176 let after = match open_image_path(after_path) {
6177 Ok(image) => image,
6178 Err(message) => return fail(cli, ExitClass::Input, message),
6179 };
6180
6181 let report = match diff(&before, &after, &DiffSettings::default()) {
6182 Ok(report) => report,
6183 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
6184 };
6185
6186 let width = cli.width.unwrap_or_else(|| console.width());
6187 let changed = report.changed_fraction * 100.0;
6188 let naive = report.naive_changed_fraction * 100.0;
6189
6190 let failed = cli
6199 .diff_threshold
6200 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
6201
6202 let render_report = |c: &Console, for_export: bool| {
6203 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
6206
6207 let has_color = c.color_system().is_some() && !cli.no_color;
6214 let is_terminal = c.is_terminal();
6215 let mut mode = cli.image_mode;
6216 if mode == ImageMode::Auto {
6217 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
6218 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
6219 }
6220 mode = if !has_color {
6221 ImageMode::Ascii
6222 } else if is_terminal && rich_art::sixel::is_probably_supported() {
6223 ImageMode::Sixel
6224 } else {
6225 ImageMode::Blocks
6226 };
6227 }
6228
6229 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
6233 if !for_export && cli.report_format == ReportFormat::Human {
6234 eprintln!("rich: no colour available, drawing the diff as ASCII art");
6235 }
6236 mode = ImageMode::Ascii;
6237 }
6238 if mode == ImageMode::Sixel && !is_terminal {
6239 if cli.image_mode != ImageMode::Sixel
6240 && !for_export
6241 && cli.report_format == ReportFormat::Human
6242 {
6243 eprintln!(
6244 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
6245 if has_color { "blocks" } else { "ASCII art" }
6246 );
6247 }
6248 mode = if has_color {
6249 ImageMode::Blocks
6250 } else {
6251 ImageMode::Ascii
6252 };
6253 }
6254
6255 match mode {
6256 ImageMode::None => {}
6257 ImageMode::Ascii => c.print(
6258 &AsciiArt::new(report.heatmap())
6259 .width(width)
6260 .color(has_color),
6261 ),
6262 ImageMode::Blocks => c.print(
6263 &BlockArt::new(report.heatmap())
6264 .width(width)
6265 .height(rows_cap),
6266 ),
6267 ImageMode::Quadrants => c.print(
6268 &rich_art::QuadrantArt::new(report.heatmap())
6269 .width(width)
6270 .height(rows_cap),
6271 ),
6272 ImageMode::Braille => c.print(
6273 &BrailleArt::new(report.heatmap())
6274 .width(width)
6275 .height(rows_cap),
6276 ),
6277 ImageMode::Sixel => {
6278 let art = SixelArt::new(report.heatmap())
6279 .width(width)
6280 .height(rows_cap);
6281 if art.encode(width).is_some() {
6284 c.print(&art);
6285 } else {
6286 if cli.report_format == ReportFormat::Human {
6287 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
6288 }
6289 c.print(
6290 &BlockArt::new(report.heatmap())
6291 .width(width)
6292 .height(rows_cap),
6293 );
6294 }
6295 }
6296 ImageMode::Auto => unreachable!("resolved above"),
6297 }
6298 if mode != ImageMode::None {
6302 c.print_str("");
6303 }
6304 c.print_str(&format!(
6305 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
6306 [dim](a plain pixel diff would say {naive:.1}%)[/]"
6307 ));
6308
6309 if report.regions.is_empty() {
6310 c.print_str("[dim]No region large enough to report.[/]");
6311 } else {
6312 let mut table = Table::new().title("Where it changed");
6313 table.add_column("#");
6314 table.add_column_justify("Share", rich::Justify::Right);
6315 table.add_column_justify("Mean ΔE", rich::Justify::Right);
6316 table.add_column_justify("Area px", rich::Justify::Right);
6317 table.add_column("Box (x,y w×h)");
6318 for (rank, r) in report.regions.iter().enumerate() {
6319 table.add_row(&[
6320 &format!("{}", rank + 1),
6321 &format!("{:.0}%", r.share_of_change * 100.0),
6322 &format!("{:.1}", r.mean_delta_e),
6323 &format!("{}", r.area_px),
6324 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
6325 ]);
6326 }
6327 c.print(&table);
6328 }
6329
6330 if let Some(limit) = cli.diff_threshold {
6334 if failed {
6335 c.print_str(&format!(
6336 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
6337 ));
6338 } else {
6339 c.print_str(&format!(
6340 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
6341 ));
6342 }
6343 }
6344 };
6345
6346 let terminal_only = Export {
6347 html_path: None,
6348 svg_path: None,
6349 png_path: None,
6350 ..*export
6351 };
6352 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
6353 if export.any_file() {
6354 let mut export_console = Console::builder()
6358 .force_terminal(false)
6359 .color_system(Some(ColorSystem::Truecolor))
6360 .no_color(cli.no_color)
6361 .width(console.width())
6362 .height(console.height())
6363 .theme(console.theme().clone())
6364 .build();
6365 export_console.install_extensions();
6366 let segments = export_console.record_output(|c| render_report(c, true));
6367 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6368 }
6369
6370 if let Err(message) = wrote {
6371 return fail(cli, ExitClass::Input, message);
6372 }
6373 if failed {
6374 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6375 }
6376 success(cli)
6377}
6378
6379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6382#[cfg_attr(not(feature = "art"), allow(dead_code))]
6383enum ImageBackdrop {
6384 Color([u8; 3]),
6386 Terminal,
6388 Checkerboard,
6390}
6391
6392impl ImageBackdrop {
6393 fn parse(value: &str) -> Option<Self> {
6394 match value {
6395 "default" => return Some(ImageBackdrop::Terminal),
6396 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6397 _ => {}
6398 }
6399 let hex = value.strip_prefix('#')?;
6400 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6401 return None;
6402 }
6403 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6404 Some(ImageBackdrop::Color([
6405 channel(0)?,
6406 channel(1)?,
6407 channel(2)?,
6408 ]))
6409 }
6410}
6411
6412#[cfg(feature = "art")]
6415fn image_color_processing(
6416 cli: &Cli,
6417) -> (
6418 rich_art::ImageColorMode,
6419 rich_art::Dither,
6420 rich_art::ColorDistance,
6421) {
6422 use rich_art::{ColorDistance, Dither, ImageColorMode};
6423 let mode = match cli.image_color.as_deref() {
6424 Some("ansi256") => ImageColorMode::Ansi256,
6425 Some("ansi16") => ImageColorMode::Ansi16,
6426 Some("grayscale") => ImageColorMode::Grayscale,
6427 _ => ImageColorMode::TrueColor,
6428 };
6429 let dither = match cli.image_dither.as_deref() {
6430 Some("floyd-steinberg") => Dither::FloydSteinberg,
6431 Some("bayer4x4") => Dither::Bayer4x4,
6432 Some("atkinson") => Dither::Atkinson,
6433 _ => Dither::None,
6434 };
6435 let distance = match cli.image_color_distance.as_deref() {
6436 Some("oklab") => ColorDistance::Oklab,
6437 _ => ColorDistance::Rgb,
6438 };
6439 (mode, dither, distance)
6440}
6441
6442#[cfg(feature = "art")]
6443fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6444 use rich_art::{AnimatedArt, Repeat, Stage, Until};
6445
6446 if cli.resources.is_empty() {
6447 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6448 }
6449 let count = cli.resources.len();
6450 const GAP: usize = 2;
6451
6452 let total = cli.width.unwrap_or_else(|| console.width());
6454 let per_gif = total
6455 .saturating_sub(GAP * count.saturating_sub(1))
6456 .checked_div(count)
6457 .unwrap_or(total)
6458 .max(8);
6459
6460 let repeat = match cli.loops {
6461 Some(0) => Repeat::Forever,
6462 Some(n) => Repeat::Times(n),
6463 None => Repeat::Times(1),
6464 };
6465
6466 let mut arts = Vec::with_capacity(count);
6467 for path in &cli.resources {
6468 match AnimatedArt::from_path(path) {
6469 Ok(art) => {
6470 let (color_mode, dither, distance) = image_color_processing(cli);
6471 arts.push(
6472 art.width(per_gif)
6473 .blocks(cli.extensions.gif_blocks())
6474 .color(!cli.no_color)
6475 .color_mode(color_mode)
6476 .dither(dither)
6477 .color_distance(distance)
6478 .repeat(repeat),
6479 );
6480 }
6481 Err(err) => {
6482 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6483 }
6484 }
6485 }
6486
6487 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6488 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6489 }
6490
6491 let mut builder = Console::builder().no_color(cli.no_color);
6493 if cli.themed() {
6494 builder = builder.theme(cli_theme(cli));
6495 }
6496 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6499 builder = builder.force_terminal(terminal == "1");
6500 }
6501 if let Some(width) = cli.width {
6502 builder = builder.width(width);
6503 }
6504 let console = builder.build();
6505 let played = if cli.animation.needs_player() && console.is_terminal() {
6506 let passes = match repeat {
6508 Repeat::Forever => None,
6509 Repeat::Times(n) => Some(n),
6510 Repeat::Once => Some(1),
6511 };
6512 animation::play(arts, GAP, passes, &cli.animation, console)
6513 } else {
6514 let fps = cli.animation.fps();
6516 let mut stage = Stage::new().gap(GAP);
6517 for art in arts {
6518 stage = stage.with(art.max_fps(fps));
6519 }
6520 if let Some(limit) = cli.animation.max_duration {
6521 stage = stage.until(Until::Elapsed(limit));
6522 }
6523 stage.play_stdout(console)
6524 };
6525 match played {
6526 Ok(()) => success(cli),
6527 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6528 }
6529}
6530
6531#[cfg(not(feature = "art"))]
6533fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6534 fail(
6535 _cli,
6536 ExitClass::Usage,
6537 "this build has no GIF support (rebuild with the `art` feature)",
6538 )
6539}
6540
6541struct Export<'a> {
6544 html_path: Option<&'a str>,
6546 svg_path: Option<&'a str>,
6548 png_path: Option<&'a str>,
6550 svg_title: &'a str,
6552 pager: bool,
6554 auto_pager: bool,
6555}
6556
6557impl Export<'_> {
6558 fn terminal_only(&self) -> bool {
6561 !watch::capturing() && !self.any_file() && !self.pager && !self.auto_pager
6562 }
6563
6564 fn any_file(&self) -> bool {
6567 self.html_path.is_some() || self.svg_path.is_some() || self.png_path.is_some()
6568 }
6569}
6570
6571fn should_page(
6582 explicit: bool,
6583 automatic: bool,
6584 terminal: bool,
6585 lines: usize,
6586 height: usize,
6587) -> bool {
6588 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6589}
6590
6591fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6592 if export.terminal_only() {
6593 render(console);
6594 return Ok(());
6595 }
6596 emit_segments(console, export, console.record_output(render))
6597}
6598
6599fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6602 if watch::capturing() {
6603 watch::capture_segments(segments);
6605 return Ok(());
6606 }
6607 let mut first_error = None;
6608
6609 let terminal = console.segments_to_string(&segments);
6611 if should_page(
6612 export.pager,
6613 export.auto_pager,
6614 std::io::stdout().is_terminal(),
6615 terminal.lines().count(),
6616 console.height(),
6617 ) {
6618 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6621 first_error = Some(format!("cannot page output: {err}"));
6622 }
6623 } else {
6624 let stdout = std::io::stdout();
6625 let mut lock = stdout.lock();
6626 if let Err(err) = lock.write_all(terminal.as_bytes()) {
6627 first_error = Some(format!("could not write output: {err}"));
6628 }
6629 }
6630
6631 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6632}
6633
6634fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6635 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6636 let segments = filtered.as_slice();
6637 let mut first_error = None;
6638 if let Some(path) = export.html_path {
6639 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6641 if let Err(err) = std::fs::write(fs_path(path), html) {
6642 first_error = Some(format!("failed to save HTML: {err}"));
6643 }
6644 }
6645 if let Some(path) = export.svg_path {
6646 let svg = rich::svg::export_svg(
6647 segments,
6648 &rich::SVG_EXPORT_THEME,
6649 export.svg_title,
6650 "rich-cli",
6653 console.width(),
6654 );
6655 if let Err(err) = std::fs::write(fs_path(path), svg) {
6656 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6657 }
6658 }
6659 if let Some(path) = export.png_path {
6660 if let Err(message) = save_png(path, segments, export.svg_title, console.width()) {
6661 first_error.get_or_insert(message);
6662 }
6663 }
6664 first_error.map_or(Ok(()), Err)
6665}
6666
6667#[cfg(feature = "png")]
6671fn save_png(path: &str, segments: &[Segment], title: &str, width: usize) -> Result<(), String> {
6672 use rich_record::render::png::{segments_png, PngOptions};
6673 let options = PngOptions {
6674 title,
6675 columns: Some(width),
6676 ..PngOptions::default()
6677 };
6678 let fonts = rich_record::render::raster::Fonts::embedded();
6679 let png = segments_png(segments, &fonts, &options)
6680 .map_err(|err| format!("failed to save PNG: {err}"))?;
6681 std::fs::write(fs_path(path), png).map_err(|err| format!("failed to save PNG: {err}"))
6682}
6683
6684#[cfg(not(feature = "png"))]
6686fn save_png(_: &str, _: &[Segment], _: &str, _: usize) -> Result<(), String> {
6687 Err("--export-png needs a build with the png feature".into())
6688}
6689
6690fn print_help(no_color: bool) {
6692 cli_spec::print_help(no_color);
6693}
6694
6695fn build_demo_console(no_color: bool) -> Console {
6696 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6701 let mut console = Console::builder()
6702 .force_terminal(to_terminal)
6703 .no_color(no_color)
6704 .color_system(if to_terminal {
6705 Some(ColorSystem::Truecolor)
6706 } else {
6707 None
6708 })
6709 .theme(rich_ext::extended_theme())
6712 .build();
6713 console.install_extensions();
6714 console
6715}
6716
6717fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6718 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6719 let console = build_demo_console(no_color);
6720
6721 demo::section(&console, delay, "rs-rich-cli");
6722 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6723 console.print_str(
6724 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6725 );
6726
6727 demo::section(&console, delay, "markup · color · theme · extension");
6729 console.print_str(
6730 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6731 );
6732 console.print_str(
6733 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6734 );
6735 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6736 let mut themed = build_demo_console(no_color);
6738 let release =
6739 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6740 themed.use_theme(release).print_str(
6741 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6742 );
6743 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6744 let hl = Console::builder()
6750 .force_terminal(to_terminal)
6751 .no_color(no_color)
6752 .color_system(if to_terminal {
6753 Some(ColorSystem::Truecolor)
6754 } else {
6755 None
6756 })
6757 .highlight(true)
6758 .build();
6759 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6760 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6762 ISO8601Highlighter::new().highlight(&mut stamp);
6763 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6764 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6765
6766 demo::section(&console, delay, "rule");
6768 console.print(&Rule::line());
6769 demo::section(&console, delay, "centered");
6770 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6771 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6772
6773 demo::section(&console, delay, "panel");
6775 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6776 console.print(
6777 &Panel::new(text("square box"))
6778 .box_set(SQUARE)
6779 .title("square"),
6780 );
6781 console.print(
6782 &Panel::new(text("double box"))
6783 .box_set(DOUBLE)
6784 .title("double"),
6785 );
6786 console.print(
6787 &Panel::new(text("title + subtitle"))
6788 .title("top")
6789 .subtitle("bottom"),
6790 );
6791 let legacy = Console::builder()
6793 .force_terminal(to_terminal)
6794 .no_color(no_color)
6795 .color_system(if to_terminal {
6796 Some(ColorSystem::Truecolor)
6797 } else {
6798 None
6799 })
6800 .width(console.width())
6801 .legacy_windows(true)
6802 .build();
6803 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6804
6805 demo::section(&console, delay, "padding");
6807 console.print(
6808 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6809 );
6810
6811 demo::section(&console, delay, "styled");
6813 console.print(&Styled::new(
6814 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6815 Style::parse("green").unwrap(),
6816 ));
6817
6818 demo::section(&console, delay, "align");
6820 console.print(&Align::left(text("← left")));
6821 console.print(&Align::center(text("center")));
6822 console.print(&Align::right(text("right →")));
6823
6824 demo::section(&console, delay, "justify");
6826 console.print_justified("left justified", Justify::Left);
6827 console.print_justified("centered", Justify::Center);
6828 console.print_justified("right justified", Justify::Right);
6829
6830 demo::section(&console, delay, "constrain (width 24)");
6832 console.print(&Constrain::new(
6833 Box::new(Panel::new(text("constrained")).title("≤24")),
6834 Some(24),
6835 ));
6836
6837 demo::section(&console, delay, "table");
6839 let mut table = Table::new().title("ported renderables");
6840 table
6842 .add_column("Renderable")
6843 .column_style(Style::parse("cyan").unwrap());
6844 table.add_column("Module").column_width(12);
6845 table.add_column_justify("Parity", Justify::Center);
6846 for (renderable, module, parity) in [
6847 ("Text / markup", "text, markup", "✓"),
6848 ("Rule", "rule", "✓"),
6849 ("Panel", "panel", "✓"),
6850 ("Padding", "padding", "✓"),
6851 ("Align", "align", "✓"),
6852 ("Constrain", "constrain", "✓"),
6853 ("Columns", "columns", "✓"),
6854 ("Table", "table", "✓"),
6855 ("Tree", "tree", "✓"),
6856 ("ProgressBar", "progress_bar", "✓"),
6857 ] {
6858 table.add_row(&[renderable, module, parity]);
6859 }
6860 console.print(&table);
6861
6862 demo::section(&console, delay, "columns");
6864 let months = [
6865 "January",
6866 "February",
6867 "March",
6868 "April",
6869 "May",
6870 "June",
6871 "July",
6872 "August",
6873 "September",
6874 "October",
6875 "November",
6876 "December",
6877 ];
6878 console.print(&Columns::new(
6879 months.iter().map(|m| m.to_string()).collect(),
6880 ));
6881
6882 demo::section(&console, delay, "tree");
6884 let mut tree = Tree::new("rs-rich-cli");
6885 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6886 core.add("color · style · text · console");
6887 core.add("box · rule · panel · padding · align");
6888 core.add("table · tree · wrap · filesize");
6889 tree.add("crates/rich-ext (our extensions)");
6890 tree.add("crates/rich-cli (this binary)");
6891 console.print(&tree);
6892
6893 demo::section(&console, delay, "progress bar");
6895 for pct in [0.0, 33.0, 66.0, 100.0] {
6896 console.print(&ProgressBar::new(100.0, pct).width(48));
6897 }
6898
6899 demo::section(&console, delay, "progress");
6903 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6904 let set_time =
6905 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6906 let reader = clock.clone();
6907 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6908 let mut progress = Progress::new().clock(now.clone());
6909 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6910 let processing = progress.add_task("Processing", 100.0, 0.0);
6911 progress.add_task("Waiting", 100.0, 0.0);
6912 set_time(4.0);
6913 progress.advance(downloading, 20.0);
6914 progress.advance(processing, 60.0);
6915 set_time(8.0);
6916 progress.advance(downloading, 30.0);
6917 progress.advance(processing, 40.0);
6918 console.print(&progress);
6919
6920 let mut transfer = Progress::new().clock(now).columns(vec![
6922 ProgressColumn::spinner(),
6923 ProgressColumn::Description,
6924 ProgressColumn::Bar,
6925 ProgressColumn::Download,
6926 ProgressColumn::TransferSpeed,
6927 ProgressColumn::TimeElapsed,
6928 ProgressColumn::time_remaining(),
6929 ]);
6930 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6931 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6932 set_time(12.0);
6933 transfer.advance(iso, 1_200_000_000.0);
6934 transfer.advance(notes, 20_000.0);
6935 set_time(20.0);
6936 transfer.advance(iso, 900_000_000.0);
6937 transfer.advance(notes, 22_000.0);
6938 console.print(&transfer);
6939
6940 let mut mofn = Progress::new().columns(vec![
6942 ProgressColumn::Description,
6943 ProgressColumn::Bar,
6944 ProgressColumn::MofN,
6945 ]);
6946 mofn.add_task("Files", 8.0, 3.0);
6947 mofn.add_task("Chunks", 128.0, 128.0);
6948 console.print(&mofn);
6949
6950 let mut download = Progress::new().columns(vec![
6952 ProgressColumn::Description,
6953 ProgressColumn::Bar,
6954 ProgressColumn::Download,
6955 ]);
6956 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6957 console.print(&download);
6958
6959 demo::section(&console, delay, "json");
6961 if let Ok(json) = Json::new(
6962 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6963 ) {
6964 console.print(&json);
6965 }
6966
6967 demo::section(&console, delay, "pretty");
6969 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6970 console.print(&Pretty::new(&value));
6971
6972 demo::section(&console, delay, "log");
6974 let log = LogRender::new().show_level(true);
6975 for (level, message) in [
6976 (LogLevel::Info, "server started on port 8080"),
6977 (LogLevel::Debug, "cache warm: 128 entries"),
6978 (LogLevel::Warn, "disk usage at 85%"),
6979 (LogLevel::Error, "connection reset by peer"),
6980 ] {
6981 console.print(&log.render(
6982 &console,
6983 Text::new(message),
6984 Some(Text::new("[12:00:00]")),
6985 level.text(),
6986 Some("main.rs"),
6987 Some(42),
6988 None,
6989 ));
6990 }
6991
6992 demo::section(&console, delay, "traceback");
6994 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6995 let err = std::io::Error::other(cause);
6996 console.print(&Traceback::new(&err));
6997
6998 demo::section(&console, delay, "bar (range)");
7000 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
7001 console.print(&Bar::new(100.0, begin, end).width(48));
7002 }
7003
7004 demo::section(&console, delay, "markdown");
7006 console.print(&Markdown::new(
7007 "# Heading\n\nA paragraph with **bold**, *italic*, `code`, ~~struck~~ text, and a [link](https://example.com).\nTilde runs pair as upstream pairs them: a ~~~x~~~ b.\n\n- bullet item\n- another\n\n1. first\n2. second\n\n> a block quote\n\n| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n---",
7008 ));
7009
7010 demo::section(&console, delay, "syntax");
7012 console.print(&Syntax::new(
7013 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
7014 "rust",
7015 ));
7016
7017 demo::section(&console, delay, "control codes");
7019 let show_escape = |label: &str, control: &Control| {
7020 let escaped = control
7022 .as_str()
7023 .replace('\x1b', "\\x1b")
7024 .replace('\x07', "\\a")
7025 .replace('\r', "\\r");
7026 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
7027 };
7028 show_escape("clear screen", &Control::clear());
7029 show_escape("home", &Control::home());
7030 show_escape("move (2,-1)", &Control::move_(2, -1));
7031 show_escape("move_to (3,4)", &Control::move_to(3, 4));
7032 show_escape("hide cursor", &Control::show_cursor(false));
7033 show_escape("bell", &Control::bell());
7034 let live_render = LiveRender::new(text("a\nb\nc"));
7036 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
7038 let live_console = Console::builder()
7041 .force_terminal(true)
7042 .color_system(Some(ColorSystem::Truecolor))
7043 .width(20)
7044 .build();
7045 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
7046 live.start();
7047 live.update(text("frame two"));
7048 live.stop();
7049 let stream = String::from_utf8_lossy(live.writer())
7050 .replace('\x1b', "\\x1b")
7051 .replace('\r', "\\r")
7052 .replace('\n', "\\n");
7053 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
7054
7055 demo::section(&console, delay, "ansi decoder");
7057 let raw =
7058 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
7059 console.print(&Text::new(format!(
7060 " input: {}",
7061 raw.replace('\x1b', "\\x1b")
7062 )));
7063 let mut decoder = rich::AnsiDecoder::new();
7064 for line in raw.lines().map(|line| decoder.decode_line(line)) {
7065 console.print(&Text::new(" output: ").append_text(&line));
7066 }
7067
7068 demo::section(&console, delay, "capture · export_text");
7070 let plain = console.export_text(|c| {
7071 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
7072 });
7073 console.print(&Text::new(" export_text (styles stripped):"));
7074 for line in plain.lines() {
7075 console.print(&Text::new(format!(" {line}")));
7076 }
7077 let html_rule = Style::parse("bold red")
7079 .unwrap()
7080 .get_html_style(&DEFAULT_TERMINAL_THEME);
7081 console.print(&Text::new(format!(
7082 " export_html: [bold red] → <span style=\"{html_rule}\">"
7083 )));
7084
7085 demo::section(&console, delay, "layout");
7088 let panel_leaf = |title: &str, body: &str| {
7089 Layout::with_renderable(Box::new(
7090 Panel::new(text(body))
7091 .box_set(SQUARE)
7092 .title(title.to_string()),
7093 ))
7094 };
7095 let mut header = Layout::new();
7096 header.split_row(vec![
7097 panel_leaf("left", "pane A"),
7098 panel_leaf("right", "pane B"),
7099 ]);
7100 let mut root = Layout::new();
7101 root.split_column(vec![
7102 header,
7103 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
7104 ]);
7105 let grid = Console::builder()
7106 .force_terminal(true)
7107 .color_system(Some(ColorSystem::Truecolor))
7108 .width(48)
7109 .height(7)
7110 .build();
7111 for line in grid.export_text(|c| c.print(&root)).lines() {
7112 console.print(&Text::new(line));
7113 }
7114
7115 demo::section(&console, delay, "spinner (frames)");
7117 for name in ["dots", "line", "arrow", "simpleDots"] {
7118 let spinner = Spinner::new(name);
7119 let frames: String = (0..6)
7120 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
7121 .collect::<Vec<_>>()
7122 .join(" ");
7123 console.print_str(&format!(" {name:>11}: {frames}"));
7124 }
7125 let status = Status::new("Loading…").renderable().render(0.0);
7127 console.print(&Text::new(" status: ").append_text(&status));
7128
7129 demo::section(&console, delay, "csv");
7131 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
7132 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
7133 console.print(&table);
7134 }
7135
7136 demo::section(&console, delay, "filesize");
7138 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
7139 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
7140 }
7141 console.print(&Rule::line());
7142 console
7143}
7144
7145#[cfg(test)]
7146mod tests {
7147 use super::*;
7148 use rich::ColorSystem;
7149
7150 const MODE_FLAGS: &[&str] = &[
7152 "--print",
7153 "-p",
7154 "--markdown",
7155 "-m",
7156 "--json",
7157 "-j",
7158 "-J",
7159 "--syntax",
7160 "-x",
7161 "--csv",
7162 "--ipynb",
7163 "--rst",
7164 "--jsonl",
7165 "--ndjson",
7166 "--log",
7167 "--rule",
7168 "-u",
7169 "--image",
7170 "--gif",
7171 "--diff",
7172 "--inspect",
7173 "--ansi-explain",
7174 ];
7175
7176 #[test]
7179 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
7180 for flag in MODE_FLAGS {
7181 assert!(mode_flag_alias(flag).is_some(), "{flag}");
7182 }
7183 let claimed = |args: &[String]| {
7184 let mut words = Vec::new();
7185 if plugins::requested(args) {
7186 words.push("plugins");
7187 }
7188 if doctor::requested(args) {
7189 words.push("doctor");
7190 }
7191 if spin::requested(args) {
7192 words.push("spin");
7193 }
7194 #[cfg(feature = "art")]
7195 if micro::requested(args) {
7196 words.push("micro");
7197 }
7198 #[cfg(feature = "record")]
7199 if record::requested(args) {
7200 words.push("record");
7201 }
7202 #[cfg(feature = "serve")]
7203 if serve::requested(args) {
7204 words.push("serve");
7205 }
7206 #[cfg(feature = "interact")]
7207 words.extend(interactive::requested(args));
7208 #[cfg(feature = "interact")]
7209 words.extend(approve_jump::requested(args));
7210 if tools::bench_dispatch(args).unwrap_or(true) {
7211 words.push("bench");
7212 }
7213 let roots = ConfigRoots {
7214 home: None,
7215 cwd: std::env::temp_dir(),
7216 no_color_env: false,
7217 };
7218 if !matches!(config::inspect(args, &roots), Ok(None)) {
7219 words.push("config");
7220 }
7221 words
7222 };
7223 let words = [
7224 "plugins", "doctor", "record", "serve", "choose", "filter", "input", "confirm",
7225 "pager", "date", "bench", "config", "spin",
7226 ];
7227 for word in words {
7228 let plain = claimed(&[word.to_string(), "--no-color".into()]);
7230 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
7231 for flag in MODE_FLAGS {
7232 let args = [flag.to_string(), word.to_string()];
7233 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
7234 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
7235 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
7236 }
7237 }
7238 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
7239 assert!(doctor::requested(&["doctor".into()]));
7240 }
7241
7242 #[cfg(feature = "art")]
7243 #[test]
7244 fn diff_threshold_uses_the_reports_tie_rounding() {
7245 assert!(!diff_threshold_exceeded(0.25, 0.24));
7247 assert!(diff_threshold_exceeded(0.75, 0.74));
7248 assert!(!diff_threshold_exceeded(0.75, 0.76));
7249 }
7250
7251 #[test]
7252 fn detects_urls() {
7253 assert!(is_url("http://example.com"));
7254 assert!(is_url("https://example.com/x"));
7255 assert!(is_url("HTTPS://example.com"));
7257 assert!(is_url("Http://example.com"));
7258 assert!(!is_url("example.com"));
7259 assert!(!is_url("./file.md"));
7260 assert!(!is_url("-"));
7261 assert!(!is_url("")); assert!(!is_url("ht"));
7263 assert!(!is_url("ftp://example.com"));
7264 }
7265
7266 #[test]
7267 fn uninformative_extensions_defer_to_content_type() {
7268 assert!(is_uninformative_ext("txt"));
7269 assert!(is_uninformative_ext("log"));
7270 assert!(!is_uninformative_ext("py"));
7271 assert!(!is_uninformative_ext("rs"));
7272 }
7273
7274 #[test]
7275 fn resource_ext_uses_basename_without_query() {
7276 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
7277 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
7278 assert_eq!(
7279 resource_ext("https://x.com/c.py?raw=1").as_deref(),
7280 Some("py")
7281 );
7282 assert_eq!(resource_ext("https://api.example.com/data"), None);
7284 assert_eq!(resource_ext("https://x.com/"), None);
7285 assert_eq!(resource_ext("noext"), None);
7286 }
7287
7288 #[test]
7289 fn content_type_maps_mode_and_lexer() {
7290 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
7291 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
7292 assert_eq!(
7293 content_type_mode("application/json; charset=utf-8"),
7294 Some(Mode::Json)
7295 );
7296 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
7297 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
7299 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
7300 assert_eq!(content_type_lexer("text/plain"), None);
7301 }
7302
7303 #[test]
7304 fn join_source_handles_string_and_array() {
7305 use serde_json::json;
7306 assert_eq!(join_source(&json!("a\nb")), "a\nb");
7307 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
7309 assert_eq!(join_source(&json!(null)), "");
7310 }
7311
7312 #[test]
7313 fn ipynb_extension_auto_detects() {
7314 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
7315 }
7316
7317 #[test]
7318 fn io_label_builds_execution_count() {
7319 assert_eq!(
7321 io_label("In ", Some(1), "green", "#66ff00").plain(),
7322 "In [1]:"
7323 );
7324 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
7325 }
7326
7327 #[test]
7328 fn parse_padding_unpacks_like_upstream() {
7329 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
7330 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
7331 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
7332 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
7333 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
7335 }
7336
7337 #[test]
7338 fn parse_box_maps_names() {
7339 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
7340 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
7341 assert!(matches!(parse_box("none"), Ok(None)));
7343 assert!(parse_box("bogus").is_err());
7344 }
7345
7346 #[test]
7347 fn paging_policy_respects_viewport_and_redirection() {
7348 assert!(!should_page(true, true, false, 100, 24));
7349 assert!(!should_page(false, true, true, 23, 24));
7350 assert!(should_page(false, true, true, 24, 24));
7351 assert!(should_page(true, false, true, 1, 24));
7352 assert!(!should_page(false, false, true, 100, 24));
7353 }
7354
7355 #[test]
7356 fn worker_arguments_preserve_resources_named_like_commands() {
7357 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
7358 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
7359 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
7360 }
7361 }
7362
7363 #[test]
7364 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
7365 let args: Vec<String> = [
7366 "--batch",
7367 "--title",
7368 "--batch",
7369 "--jobs",
7370 "2",
7371 "json",
7372 "--export-html",
7373 "out.html",
7374 "--",
7375 "--pager",
7376 ]
7377 .into_iter()
7378 .map(str::to_string)
7379 .collect();
7380 assert_eq!(
7381 worker_args(&args),
7382 ["--no-config", "--title", "--batch", "--json"]
7383 );
7384 }
7385
7386 #[test]
7388 fn worker_arguments_keep_the_code_highlighter() {
7389 let args: Vec<String> = [
7390 "--batch",
7391 "--highlighter",
7392 "syntect",
7393 "--code-theme",
7394 "ansi_dark",
7395 "a.py",
7396 ]
7397 .into_iter()
7398 .map(str::to_string)
7399 .collect();
7400 assert_eq!(
7401 worker_args(&args),
7402 [
7403 "--no-config",
7404 "--highlighter",
7405 "syntect",
7406 "--code-theme",
7407 "ansi_dark"
7408 ]
7409 );
7410 }
7411
7412 #[test]
7413 fn parses_pager_flag() {
7414 let s = |v: &str| v.to_string();
7415 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
7416 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
7417 }
7418
7419 #[test]
7420 fn parses_watch_options_and_rejects_orphans() {
7421 let s = |v: &str| v.to_string();
7422 let cli = parse(&[
7423 s("--watch"),
7424 s("--watch-interval"),
7425 s("0.25"),
7426 s("--watch-cache"),
7427 s("file.md"),
7428 ])
7429 .unwrap()
7430 .unwrap();
7431 assert!(cli.watch);
7432 assert_eq!(cli.watch_interval, 0.25);
7433 assert!(cli.watch_cache);
7434 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7435 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7436 }
7437
7438 #[test]
7439 fn parses_multi_file_watch_options() {
7440 let s = |v: &str| v.to_string();
7441 let parse = parse_inner;
7442 let cli = parse(&[
7443 s("--watch"),
7444 s("--watch-debounce"),
7445 s("0.3"),
7446 s("--watch-poll"),
7447 s("--watch-exit-on-error"),
7448 s("a.md"),
7449 s("b.json"),
7450 ])
7451 .unwrap()
7452 .unwrap();
7453 assert_eq!(cli.resources, ["a.md", "b.json"]);
7454 assert_eq!(cli.watch_debounce, 0.3);
7455 assert!(cli.watch_poll && cli.watch_exit_on_error);
7456 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7457 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7458 assert!(!default.watch_poll && !default.watch_exit_on_error);
7459 for orphan in [
7460 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7461 vec![s("--watch-poll"), s("a.md")],
7462 vec![s("--watch-exit-on-error"), s("a.md")],
7463 ] {
7464 assert!(parse(&orphan)
7465 .err()
7466 .unwrap()
7467 .contains("only has an effect with --watch"));
7468 }
7469 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7470 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7471 }
7472 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7474 }
7475
7476 #[test]
7477 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7478 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7479 let _ = std::fs::remove_file(&path);
7480 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7481 std::fs::write(&path, "first").unwrap();
7482 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7483 std::fs::write(&path, "second content").unwrap();
7484 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7485 let _ = std::fs::remove_file(&path);
7486 assert_eq!(missing, "missing");
7487 assert_ne!(first, missing);
7488 assert_ne!(first, second);
7489 }
7490
7491 #[test]
7492 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7493 let s = |v: &str| v.to_string();
7494 let cli = parse(&[
7495 s("--no-config"),
7496 s("--batch"),
7497 s("--jobs"),
7498 s("3"),
7499 s("-o"),
7500 s("out.html"),
7501 s("--continue-on-error"),
7502 s("--collision"),
7503 s("suffix"),
7504 s("input"),
7505 ])
7506 .unwrap()
7507 .unwrap();
7508 assert!(cli.batch);
7509 assert_eq!(cli.jobs, 3);
7510 assert!(cli.continue_on_error);
7511 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7512 }
7513
7514 #[test]
7515 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7516 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7517 let _ = std::fs::remove_dir_all(&root);
7518 let nested = root.join("nested");
7519 std::fs::create_dir_all(&nested).unwrap();
7520 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7521 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7522
7523 let root_arg = root.to_string_lossy().into_owned();
7524 let glob_arg = root
7525 .join("*richbatchonly.md")
7526 .to_string_lossy()
7527 .into_owned();
7528 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7529 assert_eq!(inputs.len(), 2);
7530 assert!(inputs[0].ends_with("richbatchnested.md"));
7531 assert!(inputs[1].ends_with("richbatchonly.md"));
7532
7533 std::fs::remove_dir_all(root).unwrap();
7534 }
7535
7536 #[test]
7537 fn batch_output_suffixes_are_deterministic_for_collisions() {
7538 let inputs = ["one.md", "two.md"];
7539 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7540 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7541 assert_eq!(first, "one.html");
7542 assert_eq!(second, "two.html");
7543 assert_eq!(
7544 Path::new("one.html")
7545 .with_file_name("one-2.html")
7546 .to_string_lossy(),
7547 "one-2.html"
7548 );
7549 }
7550
7551 #[test]
7552 fn config_profiles_support_profiles_section_and_source_errors() {
7553 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7554 std::fs::create_dir_all(&root).unwrap();
7555 let config = root.join("config.toml");
7556 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7557 let args = vec![
7558 "--config".to_string(),
7559 config.to_string_lossy().into_owned(),
7560 "--profile".to_string(),
7561 "ci".to_string(),
7562 ];
7563 let merged = config_args(
7564 &args,
7565 &ConfigRoots {
7566 home: None,
7567 cwd: root.clone(),
7568 no_color_env: false,
7569 },
7570 )
7571 .unwrap();
7572 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7573 assert!(merged
7574 .windows(2)
7575 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7576
7577 let bad = root.join("bad.toml");
7578 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7579 let error = config_args(
7580 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7581 &ConfigRoots {
7582 home: None,
7583 cwd: root.clone(),
7584 no_color_env: false,
7585 },
7586 )
7587 .unwrap_err();
7588 assert!(error.contains("config "));
7589 assert!(error.contains("unknown key"));
7590 std::fs::remove_dir_all(root).unwrap();
7591 }
7592
7593 #[test]
7594 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7595 let s = |v: &str| v.to_string();
7596 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7597 .err()
7598 .unwrap();
7599 assert!(error.contains("--batch cannot be combined with --diff"));
7600 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7601 .err()
7602 .unwrap();
7603 assert!(error.contains("--batch cannot be combined with --gif"));
7604 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7606 .err()
7607 .unwrap();
7608 assert!(error.contains("--pager cannot be combined with --batch"));
7609 }
7610
7611 #[test]
7612 fn line_counts_follow_python_splitlines() {
7613 assert_eq!(python_line_count(""), 0);
7614 assert_eq!(python_line_count("a"), 1);
7615 assert_eq!(python_line_count("a\n"), 1);
7616 assert_eq!(python_line_count("a\n\n"), 2);
7617 assert_eq!(python_line_count("a\r\nb"), 2);
7618 assert_eq!(
7619 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7620 8
7621 );
7622 }
7623
7624 #[test]
7625 fn glob_matches_metacharacters_rather_than_substrings() {
7626 assert!(glob_match("*.md", "notes.md"));
7627 assert!(!glob_match("*.md", "notes.md.bak"));
7629 assert!(glob_match("a?c.md", "abc.md"));
7630 assert!(!glob_match("a?c.md", "ac.md"));
7631 assert!(glob_match("*", "anything"));
7632 assert!(glob_match("a*b*c", "axxbyyc"));
7633 assert!(!glob_match("a*b*c", "axxbyy"));
7634 }
7635
7636 #[test]
7637 fn batch_expansion_skips_symlinked_directories() {
7638 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7639 let _ = std::fs::remove_dir_all(&root);
7640 std::fs::create_dir_all(&root).unwrap();
7641 std::fs::write(root.join("a.md"), "# a").unwrap();
7642 #[cfg(unix)]
7644 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7645 #[cfg(windows)]
7646 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7647 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7648 if linked {
7649 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7650 }
7651 assert!(inputs[0].ends_with("a.md"));
7652 let _ = std::fs::remove_dir_all(&root);
7653 }
7654
7655 #[test]
7656 fn subcommand_mode_wins_over_config_mode_default() {
7657 let s = |v: &str| v.to_string();
7658 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7659 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7660 assert!(!selects_mode_explicitly(&[
7662 s("--title"),
7663 s("json"),
7664 s("file")
7665 ]));
7666 assert!(!selects_mode_explicitly(&[s("file.md")]));
7667
7668 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7669 let _ = std::fs::remove_dir_all(&root);
7670 std::fs::create_dir_all(&root).unwrap();
7671 let config = root.join("config.toml");
7672 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7673 let merged = config_args(
7674 &[
7675 "--config".to_string(),
7676 config.to_string_lossy().into_owned(),
7677 "json".to_string(),
7678 "file.json".to_string(),
7679 ],
7680 &ConfigRoots {
7681 home: None,
7682 cwd: root.clone(),
7683 no_color_env: false,
7684 },
7685 )
7686 .unwrap();
7687 assert!(
7688 !merged.iter().any(|arg| arg == "--markdown"),
7689 "config mode must not override an explicit subcommand: {merged:?}"
7690 );
7691 let _ = std::fs::remove_dir_all(&root);
7692 }
7693
7694 #[test]
7695 fn suffix_collisions_step_past_files_already_on_disk() {
7696 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7697 let _ = std::fs::remove_dir_all(&root);
7698 std::fs::create_dir_all(&root).unwrap();
7699 let target = root.join("out.html");
7700 std::fs::write(&target, "existing").unwrap();
7701 let target = target.to_string_lossy().into_owned();
7702
7703 let cli = parse(&[
7704 "--no-config".to_string(),
7705 "--batch".to_string(),
7706 "--collision".to_string(),
7707 "suffix".to_string(),
7708 "x".to_string(),
7709 ])
7710 .unwrap()
7711 .unwrap();
7712 let mut taken = std::collections::BTreeSet::new();
7713 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7714 assert!(
7715 first.ends_with("out-2.html"),
7716 "existing file must be kept: {first}"
7717 );
7718 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7719 assert!(second.ends_with("out-3.html"));
7720
7721 let mut cli_error = cli.clone();
7723 cli_error.collision = CollisionPolicy::Error;
7724 let mut taken = std::collections::BTreeSet::new();
7725 let (class, message) =
7726 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7727 assert_eq!(class, ExitClass::Input);
7728 assert!(message.contains("--overwrite"));
7729
7730 let mut cli_overwrite = cli.clone();
7732 cli_overwrite.overwrite = true;
7733 let mut taken = std::collections::BTreeSet::new();
7734 assert_eq!(
7735 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7736 target
7737 );
7738 let _ = std::fs::remove_dir_all(&root);
7739 }
7740
7741 #[test]
7742 fn batch_outputs_keep_their_export_kind() {
7743 let outputs = BatchOutputs {
7745 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7746 ..BatchOutputs::default()
7747 };
7748 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7749 assert!(outputs.svg.is_none());
7750 }
7751
7752 #[test]
7753 fn parses_export_flags() {
7754 let s = |v: &str| v.to_string();
7755 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7757 .unwrap()
7758 .unwrap();
7759 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7760 assert!(cli.export_html.is_none());
7761 assert_eq!(cli.resource.as_deref(), Some("x"));
7762
7763 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7764 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7765
7766 let cli = parse(&[
7768 s("--export-html"),
7769 s("a.html"),
7770 s("--export-svg"),
7771 s("b.svg"),
7772 s("x"),
7773 ])
7774 .unwrap()
7775 .unwrap();
7776 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7777 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7778
7779 assert!(parse(&[s("--export-html")]).is_err());
7781 }
7783
7784 #[test]
7785 fn is_number_matches_pattern() {
7786 for ok in [
7791 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7792 ] {
7793 assert!(is_number(ok), "{ok:?} should be numeric");
7794 }
7795 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7796 assert!(!is_number(no), "{no:?} should not be numeric");
7797 }
7798 }
7799
7800 #[test]
7801 fn render_csv_matches_upstream() {
7802 let table = build_csv_table(
7806 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7807 Some(','),
7808 None,
7809 None,
7810 (None, None),
7811 false,
7812 )
7813 .expect("the sniffer reads this one");
7814 let out = Console::builder()
7815 .force_terminal(true)
7816 .color_system(Some(ColorSystem::Truecolor))
7817 .width(40)
7818 .no_color(false)
7819 .build()
7820 .render_to_string(&table);
7821 let expected = concat!(
7822 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7823 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7824 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7825 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7826 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7827 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7828 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7829 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7830 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7831 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7832 );
7833 assert_eq!(out, expected);
7834 }
7835}