1use std::io::{BufRead, IsTerminal, Read, Write};
31use std::path::{Path, PathBuf};
32use std::process::ExitCode;
33
34mod authoring;
35mod batch;
36mod batch_output;
37mod batch_paths;
38#[cfg(test)]
39mod batch_races;
40mod chart;
41mod cli_spec;
42mod config;
43mod controls;
44mod demo;
45mod doctor;
46mod inspect;
47#[cfg(feature = "interact")]
48mod interactive;
49#[cfg(feature = "art")]
50mod micro;
51mod plugins;
52mod profile;
53#[cfg(feature = "record")]
54mod record;
55mod render_target;
56mod sources;
57mod structured_log;
58mod tools;
59mod viewers;
60mod watch;
61use batch::run_batch;
62use config::{config_args, ConfigRoots};
63
64use rich::cells::cell_len;
65use rich::markdown::Markdown;
66use rich::measure::Measurement;
67use rich::protocol::{LineRenderable, OwnedTableRows};
68use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
69use rich::text::Text;
70use rich::{
71 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
72 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
73 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
74 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
75 DEFAULT_TERMINAL_THEME,
76};
77use rich_data::csv::{has_header, read_rows, sniff, Dialect};
78use rich_data::infer::{InferredType, Inferrer};
79use rich_ext::cli::CliExtensions;
80use rich_ext::encoding::{has_utf16_bom, Encoding};
81use rich_ext::sanitize_terminal_controls;
82use rich_ext::ConsoleExt;
83
84fn text(content: &str) -> Box<dyn Renderable> {
86 Box::new(Text::new(content))
87}
88
89const VERSION: &str = env!("CARGO_PKG_VERSION");
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum Mode {
94 Auto,
96 Print,
98 Markdown,
100 Json,
102 Syntax,
104 Csv,
106 Ipynb,
108 Rst,
110 Gif,
112 Diff,
114 Image,
116 Rule,
118 JsonLines,
120 Log,
122 Inspect,
124 AnsiExplain,
126 View,
129 Hex,
131 Unicode,
133 Env,
135 Capture,
137 Mermaid,
139 Dot,
141 Deps,
143 Schema,
146 Chart,
148 Profile,
150}
151
152impl Mode {
153 fn streams_records(self) -> bool {
154 matches!(self, Self::JsonLines | Self::Log)
155 }
156
157 fn draws_directly(self) -> bool {
158 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
159 }
160
161 fn allows_extensions(self) -> bool {
162 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
163 && !self.streams_records()
164 }
165
166 fn accepts_multiple_resources(self) -> bool {
167 matches!(
168 self,
169 Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
170 )
171 }
172}
173
174struct ModeSpec {
175 mode: Mode,
176 primary: &'static str,
177 aliases: &'static [&'static str],
178}
179
180const MODE_SPECS: &[ModeSpec] = &[
181 ModeSpec {
182 mode: Mode::Auto,
183 primary: "auto",
184 aliases: &[],
185 },
186 ModeSpec {
187 mode: Mode::Print,
188 primary: "print",
189 aliases: &["print"],
190 },
191 ModeSpec {
192 mode: Mode::Markdown,
193 primary: "markdown",
194 aliases: &["markdown", "md"],
195 },
196 ModeSpec {
197 mode: Mode::Json,
198 primary: "json",
199 aliases: &["json"],
200 },
201 ModeSpec {
202 mode: Mode::Syntax,
203 primary: "syntax",
204 aliases: &["syntax", "code"],
205 },
206 ModeSpec {
207 mode: Mode::Csv,
208 primary: "csv",
209 aliases: &["csv", "tsv"],
210 },
211 ModeSpec {
212 mode: Mode::Ipynb,
213 primary: "ipynb",
214 aliases: &["ipynb", "notebook"],
215 },
216 ModeSpec {
217 mode: Mode::Rst,
218 primary: "rst",
219 aliases: &["rst"],
220 },
221 ModeSpec {
222 mode: Mode::Gif,
223 primary: "gif",
224 aliases: &["gif"],
225 },
226 ModeSpec {
227 mode: Mode::Diff,
228 primary: "diff",
229 aliases: &["diff"],
230 },
231 ModeSpec {
232 mode: Mode::Image,
233 primary: "image",
234 aliases: &["image"],
235 },
236 ModeSpec {
237 mode: Mode::Rule,
238 primary: "rule",
239 aliases: &["rule"],
240 },
241 ModeSpec {
242 mode: Mode::JsonLines,
243 primary: "jsonl",
244 aliases: &["jsonl", "ndjson"],
245 },
246 ModeSpec {
247 mode: Mode::Log,
248 primary: "log",
249 aliases: &["log", "logs"],
250 },
251 ModeSpec {
252 mode: Mode::Inspect,
253 primary: "inspect",
254 aliases: &["inspect"],
255 },
256 ModeSpec {
257 mode: Mode::AnsiExplain,
258 primary: "ansi",
259 aliases: &["ansi", "ansi-explain"],
260 },
261 ModeSpec {
262 mode: Mode::View,
263 primary: "view",
264 aliases: &["view"],
265 },
266 ModeSpec {
267 mode: Mode::Hex,
268 primary: "hex",
269 aliases: &["hex", "hexdump"],
270 },
271 ModeSpec {
272 mode: Mode::Unicode,
273 primary: "unicode",
274 aliases: &["unicode"],
275 },
276 ModeSpec {
277 mode: Mode::Env,
278 primary: "env",
279 aliases: &["env"],
280 },
281 ModeSpec {
282 mode: Mode::Capture,
283 primary: "capture",
284 aliases: &["capture"],
285 },
286 ModeSpec {
287 mode: Mode::Mermaid,
288 primary: "mermaid",
289 aliases: &["mermaid", "mmd"],
290 },
291 ModeSpec {
292 mode: Mode::Dot,
293 primary: "dot",
294 aliases: &["dot", "graphviz"],
295 },
296 ModeSpec {
297 mode: Mode::Deps,
298 primary: "deps",
299 aliases: &["deps"],
300 },
301 ModeSpec {
302 mode: Mode::Schema,
303 primary: "schema",
304 aliases: &["schema"],
305 },
306 ModeSpec {
307 mode: Mode::Chart,
308 primary: "chart",
309 aliases: &["chart"],
310 },
311 ModeSpec {
312 mode: Mode::Profile,
313 primary: "profile",
314 aliases: &["profile"],
315 },
316];
317
318const RENDER_MODE_FLAGS: &str =
319 "--print/--markdown/--rst/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322enum ReportFormat {
323 Human,
324 Json,
325}
326
327impl ReportFormat {
328 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
329 match option {
330 "--report" => {
331 *self = match value.ok_or("--report requires one of: human, json")? {
332 "human" => Self::Human,
333 "json" => Self::Json,
334 other => return Err(format!("unknown report format {other:?} (human, json)")),
335 };
336 }
337 "--machine-json" => *self = Self::Json,
338 _ => {}
339 }
340 Ok(())
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345enum ExitClass {
346 Success,
347 Usage,
348 Input,
349 Data,
350 #[cfg_attr(not(feature = "art"), allow(dead_code))]
351 Gate,
352}
353
354impl ExitClass {
355 fn code(self) -> u8 {
356 match self {
357 Self::Success => 0,
358 Self::Usage => 2,
359 Self::Input => 3,
360 Self::Data => 4,
361 Self::Gate => 5,
362 }
363 }
364
365 fn name(self) -> &'static str {
366 match self {
367 Self::Success => "success",
368 Self::Usage => "usage",
369 Self::Input => "input",
370 Self::Data => "data",
371 Self::Gate => "gate",
372 }
373 }
374
375 fn exit_code(self) -> ExitCode {
376 ExitCode::from(self.code())
377 }
378}
379
380#[derive(Clone, Copy, PartialEq, Eq, Debug)]
387enum ImageMode {
388 Auto,
390 Sixel,
392 Blocks,
394 Braille,
396 Quadrants,
398 Ascii,
400 None,
402}
403
404impl std::str::FromStr for ImageMode {
405 type Err = String;
406
407 fn from_str(value: &str) -> Result<Self, Self::Err> {
408 match value.to_ascii_lowercase().as_str() {
409 "auto" => Ok(Self::Auto),
410 "sixel" => Ok(Self::Sixel),
411 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
412 "braille" => Ok(Self::Braille),
413 "quadrants" | "quadrant" => Ok(Self::Quadrants),
414 "ascii" | "art" => Ok(Self::Ascii),
415 "none" | "off" => Ok(Self::None),
416 other => Err(format!(
417 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
418 )),
419 }
420 }
421}
422
423#[derive(Clone, Debug, Default, PartialEq)]
427struct SourceOptions {
428 head: Option<usize>,
430 tail: Option<usize>,
431 line_numbers: bool,
433 guides: bool,
435 lexer: Option<String>,
437 no_wrap: bool,
439 infer: bool,
443}
444
445impl SourceOptions {
446 fn wants_syntax(&self) -> bool {
448 self.head.is_some()
449 || self.tail.is_some()
450 || self.line_numbers
451 || self.guides
452 || self.lexer.is_some()
453 || self.no_wrap
454 }
455
456 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
461 let lines = python_line_count(code) as i64;
462 match (self.head, self.tail) {
463 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
464 (Some(head), None) => Ok(Some((1, head as i64))),
465 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
466 (None, None) => Ok(None),
467 }
468 }
469
470 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
472 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
473 .line_numbers(self.line_numbers)
474 .indent_guides(self.guides)
475 .word_wrap(!self.no_wrap);
476 if let Some((start, end)) = self.line_range(code)? {
477 syntax = syntax.line_range(Some(start), Some(end));
478 }
479 Ok(syntax)
480 }
481}
482
483fn python_line_count(text: &str) -> usize {
487 let mut count = 0;
488 let mut chars = text.chars().peekable();
489 let mut open = false;
490 while let Some(c) = chars.next() {
491 match c {
492 '\r' => {
493 if chars.peek() == Some(&'\n') {
494 chars.next();
495 }
496 count += 1;
497 open = false;
498 }
499 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
500 | '\u{2029}' => {
501 count += 1;
502 open = false;
503 }
504 _ => open = true,
505 }
506 }
507 count + usize::from(open)
508}
509
510#[derive(Clone)]
512struct Cli {
513 mode: Mode,
514 resource: Option<String>,
515 #[cfg_attr(not(feature = "art"), allow(dead_code))]
519 resources: Vec<String>,
520 #[cfg_attr(not(feature = "art"), allow(dead_code))]
522 loops: Option<usize>,
523 #[cfg_attr(not(feature = "art"), allow(dead_code))]
526 diff_threshold: Option<f32>,
527 #[cfg_attr(not(feature = "art"), allow(dead_code))]
529 image_mode: ImageMode,
530 #[cfg_attr(not(feature = "art"), allow(dead_code))]
531 image_fit: Option<String>,
532 #[cfg_attr(not(feature = "art"), allow(dead_code))]
533 image_anchor: Option<String>,
534 #[cfg_attr(not(feature = "art"), allow(dead_code))]
535 image_background: Option<ImageBackdrop>,
536 #[cfg_attr(not(feature = "art"), allow(dead_code))]
537 image_color: Option<String>,
538 #[cfg_attr(not(feature = "art"), allow(dead_code))]
539 image_dither: Option<String>,
540 #[cfg_attr(not(feature = "art"), allow(dead_code))]
541 image_color_distance: Option<String>,
542 #[cfg_attr(not(feature = "art"), allow(dead_code))]
543 image_rotate: Option<u16>,
544 #[cfg_attr(not(feature = "art"), allow(dead_code))]
545 image_flip_horizontal: bool,
546 #[cfg_attr(not(feature = "art"), allow(dead_code))]
547 image_flip_vertical: bool,
548 #[cfg_attr(not(feature = "art"), allow(dead_code))]
549 image_grayscale: bool,
550 #[cfg_attr(not(feature = "art"), allow(dead_code))]
551 image_max_width: Option<usize>,
552 #[cfg_attr(not(feature = "art"), allow(dead_code))]
553 image_max_height: Option<usize>,
554 #[cfg_attr(not(feature = "art"), allow(dead_code))]
556 image_brightness: Option<f32>,
557 #[cfg_attr(not(feature = "art"), allow(dead_code))]
558 image_contrast: Option<f32>,
559 #[cfg_attr(not(feature = "art"), allow(dead_code))]
560 image_gamma: Option<f32>,
561 log_presentation: String,
562 mermaid_backend: Option<MermaidBackend>,
565 dot_backend: Option<sources::DotBackend>,
568 graph_sources: sources::GraphSourceOptions,
570 chart: chart::ChartOptions,
572 schema: sources::SchemaOptions,
574 profile: profile::ProfileOptions,
576 highlighter: Option<String>,
578 code_theme: Option<String>,
580 plugins: Vec<String>,
583 #[cfg_attr(not(feature = "art"), allow(dead_code))]
586 micro_project: bool,
587 theme_styles: std::collections::BTreeMap<String, Style>,
588 theme_file_styles: std::collections::BTreeMap<String, Style>,
591 #[cfg_attr(not(feature = "art"), allow(dead_code))]
594 height: Option<usize>,
595 extensions: CliExtensions,
596 width: Option<usize>,
597 justify: Option<Justify>,
598 text_justify: Option<Justify>,
602 emoji: bool,
605 soft: bool,
607 max_width: Option<usize>,
609 rule_style: Option<Style>,
611 rule_char: Option<String>,
612 force_terminal: bool,
614 source: SourceOptions,
616 no_color: bool,
617 export_html: Option<String>,
620 export_svg: Option<String>,
622 panel: Option<BoxSet>,
626 padding: Option<(usize, usize, usize, usize)>,
628 title: Option<String>,
630 caption: Option<String>,
632 panel_style: Option<Style>,
634 style: Option<Style>,
637 expand: bool,
640 pager: bool,
642 auto_pager: bool,
643 hyperlinks: bool,
647 sanitize: bool,
649 report_format: ReportFormat,
652 watch: bool,
654 watch_interval: f64,
656 watch_cache: bool,
658 watch_debounce: f64,
661 watch_poll: bool,
663 watch_exit_on_error: bool,
665 batch: bool,
666 progress: bool,
667 continue_on_error: bool,
668 jobs: usize,
669 dry_run: bool,
670 batch_paths: batch_paths::BatchPathOptions,
671 worker_args: Vec<String>,
672 overwrite: bool,
673 collision: CollisionPolicy,
674 data: inspect::DataOptions,
675 tools: tools::ToolOptions,
676 viewers: viewers::ViewerOptions,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681enum MermaidBackend {
682 Text,
684 Mmdc,
687 Off,
689}
690
691impl MermaidBackend {
692 fn parse(value: &str) -> Result<Self, String> {
693 match value {
694 "text" => Ok(Self::Text),
695 "off" => Ok(Self::Off),
696 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
697 "mmdc" => Err(
698 "--mermaid-backend mmdc needs a build with the mmdc feature \
699 (cargo install rs-rich-cli --features mmdc)"
700 .into(),
701 ),
702 other => Err(format!(
703 "unknown Mermaid backend {other:?} (text, mmdc, off)"
704 )),
705 }
706 }
707}
708
709#[cfg(feature = "mermaid")]
711fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
712 rich_mermaid::MermaidOptions {
713 backend: match backend {
714 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
715 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
716 },
717 ..rich_mermaid::MermaidOptions::default()
718 }
719}
720
721fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
724 plugin_registry_with(mermaid, true)
725}
726
727fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
730 let mut registry = builtin_registry(mermaid, dot);
731 plugins::add_to(&mut registry);
732 registry
733}
734
735fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
738 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
739 if dot {
740 registry
741 .add_plugin(&rich_diagram::plugin::DotPlugin::default())
742 .expect("the DOT plugin registers cleanly");
743 }
744 #[cfg(feature = "mermaid")]
745 if mermaid != MermaidBackend::Off {
746 registry
747 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
748 .expect("the Mermaid plugin registers cleanly");
749 }
750 #[cfg(not(feature = "mermaid"))]
751 let _ = mermaid;
752 #[cfg(feature = "lumis")]
753 registry
754 .add_plugin(&rich_lumis::LumisPlugin)
755 .expect("the lumis plugin registers cleanly");
756 registry
757}
758
759fn code_highlighting(
763 highlighter: Option<&str>,
764 theme: Option<&str>,
765) -> Result<Option<rich::CodeHighlighting>, String> {
766 if highlighter.is_none() && theme.is_none() {
767 return Ok(None);
768 }
769 let mut registry = plugin_registry(MermaidBackend::Off);
770 let name = highlighter.unwrap_or("syntect");
771 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
772 return Err(match error {
773 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
774 if name == "lumis" && !cfg!(feature = "lumis") =>
775 {
776 format!(
777 "{error} (lumis needs a build with the lumis feature: cargo install \
778 rs-rich-cli --features lumis)"
779 )
780 }
781 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
782 let themes = registry
783 .code_highlighter(name)
784 .map(|engine| engine.themes())
785 .unwrap_or_default();
786 if themes.len() <= 40 {
787 format!("{error}; its themes: {}", themes.join(", "))
788 } else {
789 format!(
790 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
791 lists them all",
792 themes.len(),
793 themes[..12].join(", ")
794 )
795 }
796 }
797 other => other.to_string(),
798 });
799 }
800 Ok(registry.code_highlighting())
801}
802
803fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
805 use rich::ConsoleCodeHighlighting;
806 if let Ok(Some(highlighting)) =
807 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
808 {
809 console.set_code_highlighting(Some(highlighting));
810 }
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814enum CollisionPolicy {
815 Error,
816 Overwrite,
817 Suffix,
818}
819
820fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
832 Markdown::new(source).hyperlinks(hyperlinks)
833}
834
835pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
850 dispatch(remember_raw_args(args))
851}
852
853static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
857 std::sync::Mutex::new(Vec::new());
858
859fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
868 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
869 let args = args
870 .into_iter()
871 .map(|arg| match arg.into_string() {
872 Ok(arg) => arg,
873 Err(arg) => {
874 let spelling = escaped_path(&arg);
875 match raw.iter_mut().find(|(known, _)| *known == spelling) {
876 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
877 Some(_) => {}
878 None => raw.push((spelling.clone(), Some(arg))),
879 }
880 spelling
881 }
882 })
883 .collect();
884 if let Ok(mut slot) = RAW_ARGS.lock() {
885 *slot = raw;
886 }
887 args
888}
889
890pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
894 RAW_ARGS
895 .lock()
896 .ok()
897 .and_then(|raw| {
898 raw.iter()
899 .find(|(spelling, _)| spelling == arg)
900 .and_then(|(_, original)| original.clone())
901 })
902 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
903}
904
905pub(crate) fn text_arg(arg: &str) -> String {
909 if !arg.starts_with('\0') {
910 return arg.to_string();
911 }
912 RAW_ARGS
913 .lock()
914 .ok()
915 .and_then(|raw| {
916 raw.iter()
917 .find(|(spelling, _)| spelling == arg)
918 .and_then(|(_, original)| original.clone())
919 })
920 .map_or_else(
921 || arg.to_string(),
922 |original| original.to_string_lossy().into_owned(),
923 )
924}
925
926pub(crate) fn path_arg(path: &Path) -> String {
934 if let Some(text) = path.to_str() {
935 return text.to_string();
936 }
937 let spelling = escaped_path(path.as_os_str());
938 if let Ok(mut raw) = RAW_ARGS.lock() {
939 let original = path.as_os_str().to_owned();
940 match raw.iter_mut().find(|(known, _)| *known == spelling) {
941 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
942 Some(_) => {}
943 None => raw.push((spelling.clone(), Some(original))),
944 }
945 }
946 spelling
947}
948
949#[cfg(unix)]
952fn escaped_path(path: &std::ffi::OsStr) -> String {
953 use std::os::unix::ffi::OsStrExt;
954 let mut out = String::from('\0');
955 for chunk in path.as_bytes().utf8_chunks() {
956 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
957 for byte in chunk.invalid() {
958 out.push_str(&format!("\\x{byte:02X}"));
959 }
960 }
961 out
962}
963
964#[cfg(not(unix))]
965fn escaped_path(path: &std::ffi::OsStr) -> String {
966 format!("\0{}", path.to_string_lossy())
967}
968
969pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
979 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
982 if let Ok(mut slot) = SELF_PROGRAM.lock() {
983 *slot = program;
984 }
985 run(args)
986}
987
988static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
990
991static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
993
994fn dispatch(args: Vec<String>) -> ExitCode {
995 plugins::install(Vec::new());
998 if demo::requested(&args) {
999 return demo::dispatch(&args);
1000 }
1001 if doctor::requested(&args) {
1002 return doctor::dispatch(&args);
1003 }
1004 if plugins::requested(&args) {
1005 return plugins::dispatch(&args);
1006 }
1007 #[cfg(feature = "art")]
1008 if micro::requested(&args) {
1009 return micro::dispatch(&args);
1010 }
1011 #[cfg(feature = "interact")]
1012 if let Some(command) = interactive::requested(&args) {
1013 return interactive::dispatch(command, &args);
1014 }
1015 #[cfg(feature = "record")]
1016 if record::requested(&args) {
1017 return record::dispatch(&args);
1018 }
1019 match parse(&args) {
1020 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
1022 if let Err(message) = plugins::check_linked() {
1023 return fail(&cli, ExitClass::Usage, message);
1024 }
1025 if !cli.plugins.is_empty() {
1026 match plugins::load(&cli.plugins) {
1027 Ok(loaded) => plugins::install(loaded),
1028 Err((class, message)) => return fail(&cli, class, message),
1029 }
1030 }
1031 if let Some(message) = cli
1033 .data
1034 .unknown_transform(&plugin_registry(MermaidBackend::Text))
1035 {
1036 return fail(&cli, ExitClass::Usage, message);
1037 }
1038 if cli.batch {
1039 if let Err(error) = batch::install_interrupt_handler() {
1040 return fail(
1041 &cli,
1042 ExitClass::Input,
1043 format!("cannot install batch interruption handler: {error}"),
1044 );
1045 }
1046 }
1047 run_cli(cli)
1048 }
1049 Err(message) => emit_error(
1050 wants_json_report(&args),
1051 ExitClass::Usage,
1052 &format!("{message} (try --help)"),
1053 ),
1054 }
1055}
1056
1057fn self_command() -> std::io::Result<std::process::Command> {
1061 let program = SELF_PROGRAM
1062 .lock()
1063 .map(|program| program.clone())
1064 .unwrap_or_default();
1065 Ok(match program.split_first() {
1066 Some((executable, rest)) => {
1067 let mut command = std::process::Command::new(executable);
1068 command.args(rest);
1069 command
1070 }
1071 None => std::process::Command::new(std::env::current_exe()?),
1072 })
1073}
1074
1075const VALUE_OPTIONS: &[&str] = &[
1078 "--batch-input-root",
1079 "--batch-name-template",
1080 "--log-presentation",
1081 "--mermaid-backend",
1082 "--dot-backend",
1083 "--metadata",
1084 "--why",
1085 "--package",
1086 "--timings",
1087 "--audit",
1088 "--depth",
1089 "--kind",
1090 "--x",
1091 "--y",
1092 "--sample",
1093 "--columns",
1094 "--top",
1095 "--highlighter",
1096 "--code-theme",
1097 "--plugin",
1098 "-w",
1099 "--width",
1100 "-o",
1101 "--export-html",
1102 "--export-svg",
1103 "-a",
1104 "--panel",
1105 "-d",
1106 "--padding",
1107 "-h",
1108 "--head",
1109 "-t",
1110 "--tail",
1111 "--lexer",
1112 "-W",
1113 "--max-width",
1114 "--rule-style",
1115 "--rule-char",
1116 "--title",
1117 "--caption",
1118 "-s",
1119 "--style",
1120 "-S",
1121 "--panel-style",
1122 "--report",
1123 "--image-mode",
1124 "--image-fit",
1125 "--image-anchor",
1126 "--image-background",
1127 "--height",
1128 "--theme",
1129 "--theme-style",
1130 "--theme-file",
1131 "--image-color",
1132 "--image-dither",
1133 "--image-color-distance",
1134 "--image-rotate",
1135 "--image-max-width",
1136 "--image-max-height",
1137 "--image-brightness",
1138 "--image-contrast",
1139 "--image-gamma",
1140 "--demo-section",
1141 "--demo-delay",
1142 "--watch-interval",
1143 "--interval",
1144 "--watch-debounce",
1145 "--gif-mode",
1146 "--encoding",
1147 "--threshold",
1148 "--loop",
1149 "--jobs",
1150 "--collision",
1151 "--format",
1152 "--select",
1153 "--filter",
1154 "--highlight",
1155 "--transform",
1156 "--find",
1157 "--max-depth",
1158 "--max-length",
1159 "--compare",
1160 "--context",
1161 "--language",
1162 "--search",
1163 "--offset",
1164 "--length",
1165 "--bytes-per-line",
1166 "--group",
1167 "--limit",
1168 "--cast",
1169 "--redact-pattern",
1170 "--config",
1171 "--profile",
1172];
1173
1174fn selects_mode_explicitly(args: &[String]) -> bool {
1178 explicit_mode(args).is_some()
1179}
1180
1181fn explicit_mode(args: &[String]) -> Option<Mode> {
1183 let mut iter = args.iter();
1184 let mut seen_positional = false;
1185 while let Some(arg) = iter.next() {
1186 if arg == "--" {
1187 break;
1188 }
1189 if VALUE_OPTIONS.contains(&arg.as_str()) {
1190 iter.next();
1191 continue;
1192 }
1193 if arg.starts_with('-') && arg.len() > 1 {
1194 if let Some(flag) = mode_flag_alias(arg) {
1195 return command_mode(&flag[2..]);
1196 }
1197 continue;
1198 }
1199 if !seen_positional {
1200 seen_positional = true;
1201 if let Some(mode) = command_mode(arg) {
1202 return Some(mode);
1203 }
1204 }
1205 }
1206 None
1207}
1208
1209fn command_word(args: &[String]) -> Option<&str> {
1212 let mut iter = args.iter();
1213 while let Some(arg) = iter.next() {
1214 if arg == "--" {
1215 return None;
1216 }
1217 if VALUE_OPTIONS.contains(&arg.as_str()) {
1218 iter.next();
1219 } else if !arg.starts_with('-') || arg == "-" {
1220 return command_mode(arg).map(|_| arg.as_str());
1221 }
1222 }
1223 None
1224}
1225
1226pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1232 let mut iter = args.iter();
1233 while let Some(arg) = iter.next() {
1234 if arg == "--" || mode_flag_alias(arg).is_some() {
1235 return None;
1236 }
1237 if VALUE_OPTIONS.contains(&arg.as_str()) {
1238 iter.next();
1239 } else if !arg.starts_with('-') || arg == "-" {
1240 return Some(arg);
1241 }
1242 }
1243 None
1244}
1245
1246fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1248 Some(match arg {
1249 "--print" | "-p" => "--print",
1250 "--markdown" | "-m" => "--markdown",
1251 "--json" | "-j" | "-J" => "--json",
1252 "--syntax" | "-x" => "--syntax",
1253 "--csv" => "--csv",
1254 "--ipynb" => "--ipynb",
1255 "--rst" => "--rst",
1256 "--jsonl" | "--ndjson" => "--jsonl",
1257 "--log" => "--log",
1258 "--rule" | "-u" => "--rule",
1259 "--image" => "--image",
1260 "--gif" => "--gif",
1261 "--diff" => "--diff",
1262 "--inspect" => "--inspect",
1263 "--ansi-explain" => "--ansi-explain",
1264 _ => return None,
1265 })
1266}
1267
1268fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1269 let message = &controls::shown(message);
1272 if json {
1273 eprintln!(
1274 "{}",
1275 serde_json::json!({
1276 "ok": false,
1277 "code": class.name(),
1278 "exit_code": class.code(),
1279 "message": message,
1280 "error": {
1281 "message": message,
1282 },
1283 })
1284 );
1285 } else {
1286 eprintln!("rich: {message}");
1287 }
1288 class.exit_code()
1289}
1290
1291fn emit_success_report(format: ReportFormat) {
1292 if format == ReportFormat::Json {
1293 eprintln!(
1294 "{}",
1295 serde_json::json!({
1296 "ok": true,
1297 "code": ExitClass::Success.name(),
1298 "exit_code": ExitClass::Success.code(),
1299 "result": {},
1300 })
1301 );
1302 }
1303}
1304
1305fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1306 let message = controls::shown(message.as_ref());
1307 if watch::capture_error(&message) {
1309 return class.exit_code();
1310 }
1311 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1312}
1313
1314fn success(cli: &Cli) -> ExitCode {
1315 if !watch::capturing() {
1316 emit_success_report(cli.report_format);
1317 }
1318 ExitClass::Success.exit_code()
1319}
1320
1321#[cfg(test)]
1328fn glob_match(pattern: &str, text: &str) -> bool {
1329 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1330 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1331 glob_match_units(&pattern, &text)
1332}
1333
1334#[derive(Clone, Copy, PartialEq, Eq)]
1337enum GlobUnit {
1338 Char(char),
1339 Byte(u8),
1340}
1341
1342fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1345 #[cfg(unix)]
1346 {
1347 use std::os::unix::ffi::OsStrExt;
1348 let mut units = Vec::new();
1349 for chunk in name.as_bytes().utf8_chunks() {
1350 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1351 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1352 }
1353 units
1354 }
1355 #[cfg(not(unix))]
1356 {
1357 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1358 }
1359}
1360
1361fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1362 const ANY: GlobUnit = GlobUnit::Char('?');
1363 const STAR: GlobUnit = GlobUnit::Char('*');
1364 let (mut p, mut t) = (0usize, 0usize);
1365 let (mut star, mut retry) = (None, 0usize);
1366 while t < text.len() {
1367 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1368 p += 1;
1369 t += 1;
1370 } else if p < pattern.len() && pattern[p] == STAR {
1371 star = Some(p);
1372 retry = t;
1373 p += 1;
1374 } else if let Some(open) = star {
1375 p = open + 1;
1376 retry += 1;
1377 t = retry;
1378 } else {
1379 return false;
1380 }
1381 }
1382 while p < pattern.len() && pattern[p] == STAR {
1383 p += 1;
1384 }
1385 p == pattern.len()
1386}
1387
1388fn has_glob_meta(value: &str) -> bool {
1389 value.contains('*') || value.contains('?')
1390}
1391
1392fn split_glob(resource: &str) -> (PathBuf, String) {
1394 let meta = resource.find(['*', '?']).unwrap_or(0);
1395 match resource[..meta].rfind(['/', '\\']) {
1396 Some(cut) => (
1397 PathBuf::from(&resource[..cut]),
1398 resource[cut + 1..].to_string(),
1399 ),
1400 None => (PathBuf::from("."), resource.to_string()),
1401 }
1402}
1403
1404fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1412 let mut stack = vec![dir.to_path_buf()];
1413 while let Some(current) = stack.pop() {
1414 batch::check_interrupted()?;
1415 let entries = std::fs::read_dir(¤t)
1416 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1417 for entry in entries {
1418 batch::check_interrupted()?;
1419 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1420 let path = entry.path();
1421 let kind = std::fs::symlink_metadata(&path)
1422 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1423 .file_type();
1424 if kind.is_symlink() {
1425 continue;
1426 }
1427 if kind.is_dir() {
1428 stack.push(path);
1429 } else if kind.is_file() {
1430 out.push(path_arg(&path));
1431 }
1432 }
1433 }
1434 Ok(())
1435}
1436
1437fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1438 let mut out = Vec::new();
1439 for resource in resources {
1440 batch::check_interrupted()?;
1441 if resource == "-" || is_url(resource) {
1442 return Err("batch resources must be local files or directories".into());
1443 }
1444 if has_glob_meta(resource) {
1445 let raw = fs_path(resource);
1449 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1450 let (dir, pattern) = if escaped {
1451 let parent = raw.parent().unwrap_or(Path::new(""));
1452 if has_glob_meta(&parent.to_string_lossy()) {
1453 return Err(format!(
1454 "batch glob {} may only use * and ? in the final path segment",
1455 controls::shown(resource)
1456 ));
1457 }
1458 let dir = if parent.as_os_str().is_empty() {
1459 PathBuf::from(".")
1460 } else {
1461 parent.to_path_buf()
1462 };
1463 (dir, glob_units(raw.file_name().unwrap_or_default()))
1464 } else {
1465 let (dir, pattern) = split_glob(resource);
1466 if pattern.contains('/') || pattern.contains('\\') {
1467 return Err(format!(
1468 "batch glob {resource:?} may only use * and ? in the final path segment"
1469 ));
1470 }
1471 (dir, pattern.chars().map(GlobUnit::Char).collect())
1472 };
1473 let entries = std::fs::read_dir(&dir)
1474 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1475 for entry in entries {
1476 batch::check_interrupted()?;
1477 let entry =
1478 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1479 let path = entry.path();
1480 if !path.is_file() {
1481 continue;
1482 }
1483 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1484 out.push(path_arg(&path));
1485 }
1486 }
1487 continue;
1488 }
1489 let path = fs_path(resource);
1490 if path.is_file() {
1491 out.push(resource.clone());
1492 } else if path.is_dir() {
1493 collect_files(&path, &mut out)?;
1494 } else {
1495 return Err(format!("batch resource does not exist: {resource}"));
1496 }
1497 }
1498 batch::check_interrupted()?;
1499 out.sort();
1500 out.dedup();
1501 batch::check_interrupted()?;
1502 if out.is_empty() {
1503 return Err("batch plan contains no files".into());
1504 }
1505 Ok(out)
1506}
1507
1508#[derive(Default, Clone)]
1514struct BatchOutputs {
1515 html: Option<String>,
1516 svg: Option<String>,
1517}
1518
1519fn batch_export_path(
1520 path: Option<&str>,
1521 input: &str,
1522 index: usize,
1523 total: usize,
1524) -> Option<String> {
1525 let path = path?;
1526 if total == 1 {
1527 return Some(path.into());
1528 }
1529 let p = fs_path(path);
1530 let input = fs_path(input);
1532 let stem = input
1533 .file_stem()
1534 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1535 let mut name = stem.to_owned();
1536 match p.extension() {
1537 Some(ext) if !ext.is_empty() => {
1538 name.push(".");
1539 name.push(ext);
1540 }
1541 _ => name.push(format!("-{index}")),
1542 }
1543 Some(path_arg(&if p.is_dir() {
1544 p.join(name)
1545 } else {
1546 p.with_file_name(name)
1547 }))
1548}
1549
1550fn suffixed_path(path: &Path, suffix: usize) -> String {
1552 let mut name = path
1553 .file_stem()
1554 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1555 .to_owned();
1556 name.push(format!("-{suffix}"));
1557 if let Some(ext) = path.extension() {
1558 name.push(".");
1559 name.push(ext);
1560 }
1561 path_arg(&path.with_file_name(name))
1562}
1563
1564fn resolve_destination(
1570 cli: &Cli,
1571 candidate: String,
1572 taken: &mut std::collections::BTreeSet<String>,
1573) -> Result<String, (ExitClass, String)> {
1574 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1575 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1576 };
1577 match cli.collision {
1578 CollisionPolicy::Overwrite => {
1579 taken.insert(candidate.clone());
1580 Ok(candidate)
1581 }
1582 CollisionPolicy::Suffix => {
1583 let mut chosen = candidate.clone();
1584 let mut suffix = 2;
1585 while occupied(&chosen, taken) {
1586 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1587 chosen = suffixed_path(&fs_path(&candidate), suffix);
1588 suffix += 1;
1589 }
1590 taken.insert(chosen.clone());
1591 Ok(chosen)
1592 }
1593 CollisionPolicy::Error => {
1594 if taken.contains(&candidate) {
1595 return Err((
1596 ExitClass::Usage,
1597 format!("batch output collision: {candidate}"),
1598 ));
1599 }
1600 if !cli.overwrite && fs_path(&candidate).exists() {
1601 return Err((
1602 ExitClass::Input,
1603 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1604 ));
1605 }
1606 taken.insert(candidate.clone());
1607 Ok(candidate)
1608 }
1609 }
1610}
1611
1612fn worker_args(args: &[String]) -> Vec<String> {
1615 let mut result = vec!["--no-config".into()];
1616 let mut iter = args.iter();
1617 let mut seen_resource = false;
1618 let mut selected_mode = false;
1619 while let Some(arg) = iter.next() {
1620 if arg == "--" {
1621 break;
1622 }
1623 if VALUE_OPTIONS.contains(&arg.as_str()) {
1624 let value = iter.next();
1625 if !matches!(
1626 arg.as_str(),
1627 "--jobs"
1628 | "--batch-input-root"
1629 | "--batch-name-template"
1630 | "--collision"
1631 | "--report"
1632 | "-o"
1633 | "--export-html"
1634 | "--export-svg"
1635 | "--config"
1636 | "--profile"
1637 ) {
1638 result.push(arg.clone());
1639 if let Some(value) = value {
1640 result.push(value.clone());
1641 }
1642 }
1643 } else if arg.starts_with('-') && arg != "-" {
1644 selected_mode |= mode_flag_alias(arg).is_some();
1645 if !matches!(
1646 arg.as_str(),
1647 "--batch"
1648 | "--batch-preserve-dirs"
1649 | "--no-batch-preserve-dirs"
1650 | "--no-batch"
1651 | "--dry-run"
1652 | "--progress"
1653 | "--no-progress"
1654 | "--continue-on-error"
1655 | "--no-continue-on-error"
1656 | "--overwrite"
1657 | "--no-overwrite"
1658 | "--machine-json"
1659 ) {
1660 result.push(arg.clone());
1661 }
1662 } else if !seen_resource {
1663 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1664 selected_mode = true;
1665 result.push(format!("--{}", mode_name(mode)));
1666 } else {
1667 seen_resource = true;
1668 }
1669 }
1670 }
1671 result
1672}
1673
1674fn wants_json_report(args: &[String]) -> bool {
1675 let mut format = ReportFormat::Human;
1676 let mut iter = args.iter();
1677 while let Some(arg) = iter.next() {
1678 match arg.as_str() {
1679 "--report" => {
1680 if let Some(value) = iter.next() {
1681 let _ = format.apply_option("--report", Some(value));
1682 }
1683 }
1684 "--machine-json" => {
1685 let _ = format.apply_option("--machine-json", None);
1686 }
1687 "--" => break,
1688 _ => {}
1689 }
1690 }
1691 format == ReportFormat::Json
1692}
1693
1694fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1706 let name = name.to_ascii_lowercase();
1707 if name == "none" {
1708 return Ok(None);
1709 }
1710 PANEL_BOXES
1711 .iter()
1712 .find(|(known, _)| *known == name)
1713 .map(|(_, set)| Some(*set))
1714 .ok_or_else(|| {
1715 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1716 format!(
1717 "unknown panel box {name:?} (use none or {})",
1718 names.join(", ")
1719 )
1720 })
1721}
1722
1723pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1725 use rich::r#box as b;
1726 &[
1727 ("ascii", b::ASCII),
1728 ("ascii2", b::ASCII2),
1729 ("square", b::SQUARE),
1730 ("rounded", b::ROUNDED),
1731 ("heavy", b::HEAVY),
1732 ("double", b::DOUBLE),
1733 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1734 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1735 ("minimal", b::MINIMAL),
1736 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1737 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1738 ("simple", b::SIMPLE),
1739 ("simple_head", b::SIMPLE_HEAD),
1740 ("simple_heavy", b::SIMPLE_HEAVY),
1741 ("horizontals", b::HORIZONTALS),
1742 ("heavy_edge", b::HEAVY_EDGE),
1743 ("heavy_head", b::HEAVY_HEAD),
1744 ("double_edge", b::DOUBLE_EDGE),
1745 ("markdown", b::MARKDOWN),
1746 ]
1747};
1748
1749fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1753 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1754 let parts: Result<Vec<usize>, _> = value
1755 .split(',')
1756 .map(|p| p.trim().parse::<usize>())
1757 .collect();
1758 let parts = parts.map_err(|_| error())?;
1759 match parts.as_slice() {
1760 [p] => Ok((*p, *p, *p, *p)),
1761 [v, h] => Ok((*v, *h, *v, *h)),
1762 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1763 _ => Err(error()),
1764 }
1765}
1766
1767fn mode_name(mode: Mode) -> &'static str {
1769 MODE_SPECS
1770 .iter()
1771 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1772 .unwrap_or("unknown")
1773}
1774
1775fn command_mode(command: &str) -> Option<Mode> {
1776 MODE_SPECS
1777 .iter()
1778 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1779}
1780
1781fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1783 let flag = match flag {
1784 "-h" | "--head" => "--head",
1785 _ => "--tail",
1786 };
1787 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1788 match value.parse::<i64>() {
1789 Ok(lines) if lines >= 1 => Ok(lines as usize),
1790 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1791 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1792 }
1793}
1794
1795fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1796 if *current != Mode::Auto && *current != mode {
1797 return Err(format!(
1798 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1799 ));
1800 }
1801 *current = mode;
1802 Ok(())
1803}
1804
1805fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1807 let roots = ConfigRoots::default();
1808 if authoring::dispatch(args)? {
1809 return Ok(None);
1810 }
1811 if tools::bench_dispatch(args)? {
1812 return Ok(None);
1813 }
1814 if let Some(output) = config::inspect(args, &roots)? {
1815 authoring::out(&format!("{output}\n"));
1816 return Ok(None);
1817 }
1818 let merged = config_args(args, &roots)?;
1819 parse_inner(&merged)
1820}
1821
1822fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1823 let mut mode = Mode::Auto;
1824 let mut resources: Vec<String> = Vec::new();
1825 let mut loops = None;
1826 let mut diff_threshold = None;
1827 let mut image_mode = ImageMode::Auto;
1828 let mut image_fit = None;
1829 let mut image_anchor = None;
1830 let mut image_background = None;
1831 let mut image_color = None;
1832 let mut image_dither = None;
1833 let mut image_color_distance = None;
1834 let mut image_rotate = None;
1835 let mut image_flip_horizontal = false;
1836 let mut image_flip_vertical = false;
1837 let mut image_grayscale = false;
1838 let mut image_max_width = None;
1839 let mut image_max_height = None;
1840 let mut image_brightness = None;
1841 let mut image_contrast = None;
1842 let mut image_gamma = None;
1843 let mut log_presentation = String::from("plain");
1844 let mut mermaid_backend = None;
1845 let mut dot_backend = None;
1846 let mut graph_sources = sources::GraphSourceOptions::default();
1847 let mut chart = chart::ChartOptions::default();
1848 let mut schema_options = sources::SchemaOptions::default();
1849 let mut profile_options = profile::ProfileOptions::default();
1850 let mut highlighter: Option<String> = None;
1851 let mut plugins: Vec<String> = Vec::new();
1852 let mut code_theme: Option<String> = None;
1853 let mut theme_styles = std::collections::BTreeMap::new();
1854 let mut theme_file_styles = std::collections::BTreeMap::new();
1855 let mut height = None;
1856 let mut extensions = CliExtensions::default();
1857 let mut data = inspect::DataOptions::default();
1858 let mut tool_options = tools::ToolOptions::default();
1859 let mut viewer_options = viewers::ViewerOptions::default();
1860 let mut paging_explicit = false;
1862 let mut ansi_word = false;
1864 let mut width = None;
1865 let mut justify = None;
1866 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1867 let mut export_html: Option<String> = None;
1868 let mut export_svg: Option<String> = None;
1869 let mut panel = None;
1870 let mut padding = None;
1871 let mut title = None;
1872 let mut caption = None;
1873 let mut panel_style = None;
1874 let mut style = None;
1875 let mut expand = false;
1876 let mut pager = false;
1877 let mut auto_pager = false;
1878 let mut hyperlinks = false;
1879 let mut source = SourceOptions::default();
1880 let mut text_flags = [false; 4];
1883 let mut emoji = false;
1884 let mut soft = false;
1885 let mut max_width = None;
1886 let mut rule_style = None;
1887 let mut rule_char = None;
1888 let mut force_terminal = false;
1889 let mut sanitize = None;
1892 let mut report_format = ReportFormat::Human;
1893 let mut watch = false;
1894 let mut watch_interval = 1.0;
1895 let mut watch_cache = false;
1896 let mut watch_debounce: Option<f64> = None;
1897 let mut watch_poll = false;
1898 let mut watch_exit_on_error = false;
1899 let mut batch = false;
1900 let mut progress = true;
1901 let mut micro_project = false;
1902 let mut continue_on_error = false;
1903 let mut jobs = 1usize;
1904 let mut dry_run = false;
1905 let mut batch_paths = batch_paths::BatchPathOptions::default();
1906 let mut overwrite = false;
1907 let mut collision = CollisionPolicy::Error;
1908 let mut end_of_options = false;
1913
1914 let mut iter = args.iter();
1915 while let Some(arg) = iter.next() {
1916 if end_of_options {
1917 resources.push(arg.to_string());
1918 continue;
1919 }
1920 if extensions.parse_option(arg, &mut iter)? {
1921 continue;
1922 }
1923 if data.parse_option(arg, &mut iter)? {
1924 continue;
1925 }
1926 if tool_options.parse_option(arg, &mut iter)? {
1927 continue;
1928 }
1929 if viewer_options.parse_option(arg, &mut iter)? {
1930 continue;
1931 }
1932 if graph_sources.parse_option(arg, &mut iter)? {
1933 continue;
1934 }
1935 if chart.parse_option(arg, &mut iter)? {
1936 continue;
1937 }
1938 if schema_options.parse_option(arg) {
1939 continue;
1940 }
1941 if profile_options.parse_option(arg, &mut iter)? {
1942 continue;
1943 }
1944 if matches!(
1945 arg.as_str(),
1946 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
1947 ) {
1948 paging_explicit = true;
1949 }
1950 match arg.as_str() {
1951 "--" => end_of_options = true,
1952 "--help" => {
1954 let no_color = no_color || cli_spec::no_color_requested(args);
1955 let help = command_word(args)
1957 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
1958 match help {
1959 Some(help) => authoring::out(&format!("{help}\n")),
1960 None => print_help(no_color),
1961 }
1962 return Ok(None);
1963 }
1964 "-V" | "-v" | "--version" => {
1965 println!("rich (rs-rich-cli) {VERSION}");
1966 return Ok(None);
1967 }
1968 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
1969 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
1970 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
1973 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
1974 "--csv" => set_mode(&mut mode, Mode::Csv)?,
1975 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
1976 "--rst" => set_mode(&mut mode, Mode::Rst)?,
1977 "--gif" => set_mode(&mut mode, Mode::Gif)?,
1978 "--diff" => set_mode(&mut mode, Mode::Diff)?,
1979 "--image" => set_mode(&mut mode, Mode::Image)?,
1980 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
1981 "--log" => set_mode(&mut mode, Mode::Log)?,
1982 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
1983 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
1984 "--highlighter" => {
1985 let value = iter
1986 .next()
1987 .ok_or("--highlighter requires a code highlighter's name")?;
1988 highlighter = Some(value.clone());
1989 }
1990 "--code-theme" => {
1991 let value = iter.next().ok_or("--code-theme requires a theme name")?;
1992 code_theme = Some(value.clone());
1993 }
1994 "--plugin" => {
1995 let path = iter.next().ok_or("--plugin requires PATH")?;
1996 plugins.push(path.clone());
1997 }
1998 "--mermaid-backend" => {
1999 let value = iter
2000 .next()
2001 .ok_or("--mermaid-backend requires text, mmdc or off")?;
2002 mermaid_backend = Some(MermaidBackend::parse(value)?);
2003 }
2004 "--dot-backend" => {
2005 let value = iter
2006 .next()
2007 .ok_or("--dot-backend requires text, graphviz or off")?;
2008 dot_backend = Some(sources::DotBackend::parse(value)?);
2009 }
2010 "--log-presentation" => {
2011 let value = iter
2012 .next()
2013 .ok_or("--log-presentation requires plain or rich")?;
2014 if !matches!(value.as_str(), "plain" | "rich") {
2015 return Err("--log-presentation requires plain or rich".into());
2016 }
2017 log_presentation = value.clone();
2018 }
2019 "--report" => {
2020 let value = iter.next().map(String::as_str);
2021 report_format.apply_option("--report", value)?;
2022 }
2023 "--machine-json" => {
2024 report_format.apply_option("--machine-json", None)?;
2025 }
2026 "--progress" => progress = true,
2027 "--no-progress" => progress = false,
2028 "--micro-project" => micro_project = true,
2029 "--no-micro-project" => micro_project = false,
2030 "--theme-style" => {
2031 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2032 let (name, value) = binding
2033 .split_once('=')
2034 .ok_or("--theme-style requires NAME=STYLE")?;
2035 config::validate_theme_binding(name, value)?;
2036 let style = Style::parse(value)
2037 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2038 theme_styles.insert(name.to_owned(), style);
2039 }
2040 "--theme-file" => {
2041 let path = iter.next().ok_or("--theme-file requires PATH")?;
2042 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2043 }
2044 "--image-color" => {
2045 const USAGE: &str =
2046 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2047 let value = iter.next().ok_or(USAGE)?;
2048 if !matches!(
2049 value.as_str(),
2050 "truecolor" | "ansi256" | "ansi16" | "grayscale"
2051 ) {
2052 return Err(USAGE.into());
2053 }
2054 image_color = Some(value.clone());
2055 }
2056 flag @ ("--image-max-width" | "--image-max-height") => {
2057 let usage = format!("{flag} requires a positive integer");
2058 let value = iter
2059 .next()
2060 .and_then(|v| v.parse::<usize>().ok())
2061 .filter(|v| *v > 0)
2062 .ok_or(usage)?;
2063 if flag == "--image-max-width" {
2064 image_max_width = Some(value);
2065 } else {
2066 image_max_height = Some(value);
2067 }
2068 }
2069 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2070 let gamma = flag == "--image-gamma";
2071 let usage = if gamma {
2072 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2073 } else {
2074 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2075 };
2076 let value = iter
2077 .next()
2078 .and_then(|v| v.parse::<f32>().ok())
2079 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2080 .ok_or(usage)?;
2081 match flag {
2082 "--image-brightness" => image_brightness = Some(value),
2083 "--image-contrast" => image_contrast = Some(value),
2084 _ => image_gamma = Some(value),
2085 }
2086 }
2087 "--image-flip-horizontal" => image_flip_horizontal = true,
2088 "--no-image-flip-horizontal" => image_flip_horizontal = false,
2089 "--image-flip-vertical" => image_flip_vertical = true,
2090 "--no-image-flip-vertical" => image_flip_vertical = false,
2091 "--image-grayscale" => image_grayscale = true,
2092 "--no-image-grayscale" => image_grayscale = false,
2093 "--image-rotate" => {
2094 let value = iter
2095 .next()
2096 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2097 image_rotate = Some(match value.as_str() {
2098 "0" => 0,
2099 "90" => 90,
2100 "180" => 180,
2101 "270" => 270,
2102 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2103 });
2104 }
2105 "--image-dither" => {
2106 const USAGE: &str =
2107 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2108 let value = iter.next().ok_or(USAGE)?;
2109 if !matches!(
2110 value.as_str(),
2111 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2112 ) {
2113 return Err(USAGE.into());
2114 }
2115 image_dither = Some(value.clone());
2116 }
2117 "--image-color-distance" => {
2118 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2119 let value = iter.next().ok_or(USAGE)?;
2120 if !matches!(value.as_str(), "rgb" | "oklab") {
2121 return Err(USAGE.into());
2122 }
2123 image_color_distance = Some(value.clone());
2124 }
2125 "--image-mode" => {
2126 let value = iter.next().ok_or(
2127 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2128 )?;
2129 image_mode = value.parse()?;
2130 }
2131 "--image-fit" => {
2132 let value = iter
2133 .next()
2134 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2135 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2136 return Err("--image-fit requires contain, cover, stretch or native".into());
2137 }
2138 image_fit = Some(value.clone());
2139 }
2140 "--image-anchor" => {
2141 let value = iter.next().ok_or("--image-anchor requires a position")?;
2142 if !matches!(
2143 value.as_str(),
2144 "center"
2145 | "top"
2146 | "bottom"
2147 | "left"
2148 | "right"
2149 | "top-left"
2150 | "top-right"
2151 | "bottom-left"
2152 | "bottom-right"
2153 ) {
2154 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2155 }
2156 image_anchor = Some(value.clone());
2157 }
2158 "--image-background" => {
2159 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2160 let value = iter.next().ok_or(USAGE)?;
2161 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2162 }
2163 "--height" => {
2164 let value = iter.next().ok_or("--height requires a number")?;
2165 let rows: usize = value
2166 .parse()
2167 .map_err(|_| format!("invalid height '{value}'"))?;
2168 if rows == 0 {
2169 return Err("--height must be at least 1".into());
2170 }
2171 height = Some(rows);
2172 }
2173 "--threshold" => {
2174 let value = iter
2175 .next()
2176 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2177 let percent: f32 = value
2178 .parse()
2179 .map_err(|_| format!("invalid threshold '{value}'"))?;
2180 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2185 return Err(format!(
2186 "threshold must be a percentage between 0 and 100, got '{value}'"
2187 ));
2188 }
2189 diff_threshold = Some(percent);
2190 }
2191 "--loop" => {
2192 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2193 loops = Some(
2194 value
2195 .parse()
2196 .map_err(|_| format!("invalid loop count {value:?}"))?,
2197 );
2198 }
2199 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2200 "-l" | "--left" => justify = Some(Justify::Left),
2201 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2202 "-r" | "--right" => {}
2203 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2204 "-c" | "--center" => {}
2205 "-L" | "--text-left" => text_flags[0] = true,
2206 "-R" | "--text-right" => text_flags[1] = true,
2207 "-C" | "--text-center" => text_flags[2] = true,
2208 "-F" | "--text-full" => text_flags[3] = true,
2209 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2210 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2211 "-n" | "--line-numbers" => source.line_numbers = true,
2212 "-g" | "--guides" => source.guides = true,
2213 "--lexer" => {
2214 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2215 }
2216 "--no-wrap" => source.no_wrap = true,
2217 "--infer" => source.infer = true,
2218 "--emoji" => emoji = true,
2219 "--soft" => soft = true,
2220 "-W" | "--max-width" => {
2221 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2222 let size: i64 = value
2224 .parse()
2225 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2226 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2227 }
2228 "--rule-style" => {
2229 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2230 rule_style = Some(
2231 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2232 );
2233 }
2234 "--rule-char" => {
2235 rule_char = Some(
2236 iter.next()
2237 .ok_or("--rule-char requires a CHARACTER")?
2238 .clone(),
2239 );
2240 }
2241 "--force-terminal" => force_terminal = true,
2242 "--no-color" => no_color = true,
2243 "--pager" => {
2244 pager = true;
2245 auto_pager = false;
2246 }
2247 "--auto-pager" => {
2248 auto_pager = true;
2249 pager = false;
2250 }
2251 "--no-pager" => {
2252 pager = false;
2253 auto_pager = false;
2254 }
2255 "--no-auto-pager" => auto_pager = false,
2256 "--color" => no_color = false,
2257 "--no-watch" => watch = false,
2258 "--no-watch-cache" => watch_cache = false,
2259 "--no-watch-poll" => watch_poll = false,
2260 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2261 "--no-batch" => batch = false,
2262 "--no-continue-on-error" => continue_on_error = false,
2263 "--no-overwrite" => {
2264 overwrite = false;
2265 collision = CollisionPolicy::Error;
2266 }
2267 "--no-sanitize" => sanitize = Some(false),
2268 "--dry-run" => dry_run = true,
2269 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2270 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2271 "--batch-input-root" => {
2272 batch_paths.input_root = Some(fs_path(
2273 iter.next().ok_or("--batch-input-root requires a path")?,
2274 ))
2275 }
2276 "--batch-name-template" => {
2277 batch_paths.template = Some(
2278 batch_paths::FilenameTemplate::parse(
2279 iter.next()
2280 .ok_or("--batch-name-template requires a template")?,
2281 )
2282 .map_err(|e| e.to_string())?,
2283 )
2284 }
2285 "--watch" => watch = true,
2286 "--watch-cache" => watch_cache = true,
2287 "--watch-poll" => watch_poll = true,
2288 "--watch-exit-on-error" => watch_exit_on_error = true,
2289 "--watch-debounce" => {
2290 let value = iter
2291 .next()
2292 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2293 let seconds = value
2294 .parse::<f64>()
2295 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2296 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2297 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2298 }
2299 watch_debounce = Some(seconds);
2300 }
2301 "--watch-interval" | "--interval" => {
2302 let value = iter
2303 .next()
2304 .ok_or("--watch-interval requires seconds greater than zero")?;
2305 watch_interval = value
2306 .parse::<f64>()
2307 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2308 if !watch_interval.is_finite() || watch_interval <= 0.0 {
2309 return Err("--watch-interval must be greater than zero".into());
2310 }
2311 }
2312 "--batch" => batch = true,
2313 "--continue-on-error" => continue_on_error = true,
2314 "--overwrite" => {
2315 overwrite = true;
2316 collision = CollisionPolicy::Overwrite;
2317 }
2318 "--collision" => {
2319 collision = match iter
2320 .next()
2321 .ok_or("--collision requires error, overwrite, or suffix")?
2322 .as_str()
2323 {
2324 "error" => CollisionPolicy::Error,
2325 "overwrite" => CollisionPolicy::Overwrite,
2326 "suffix" => CollisionPolicy::Suffix,
2327 other => return Err(format!("unknown collision policy {other:?}")),
2328 };
2329 overwrite = collision == CollisionPolicy::Overwrite;
2330 }
2331 "--jobs" => {
2332 jobs = iter
2333 .next()
2334 .ok_or("--jobs requires a positive number")?
2335 .parse()
2336 .map_err(|_| "--jobs requires a positive number".to_string())?;
2337 if jobs == 0 {
2338 return Err("--jobs must be at least 1".into());
2339 }
2340 }
2341 "--sanitize" => sanitize = Some(true),
2342 "-y" | "--hyperlinks" => hyperlinks = true,
2346 "--export-html" | "-o" => {
2350 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2351 }
2352 "--export-svg" => {
2353 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2354 }
2355 "-a" | "--panel" => {
2356 let value = iter.next().ok_or("--panel requires a box name")?;
2357 panel = parse_box(value)?;
2358 }
2359 "-d" | "--padding" => {
2360 let value = iter.next().ok_or("--padding requires a value")?;
2361 padding = Some(parse_padding(value)?);
2362 }
2363 "-e" | "--expand" => expand = true,
2364 "--title" => {
2365 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2366 }
2367 "--caption" => {
2368 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2369 }
2370 "-s" | "--style" => {
2375 let value = iter.next().ok_or("--style requires a value")?;
2376 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2377 }
2378 "-S" | "--panel-style" => {
2379 let value = iter.next().ok_or("--panel-style requires a value")?;
2380 panel_style =
2381 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2382 }
2383 "-w" | "--width" => {
2384 let value = iter.next().ok_or("--width requires a number")?;
2385 let columns: usize = value.parse().map_err(|_| {
2386 if std::path::Path::new(value).exists() {
2390 format!("--width needs a number, but got the file '{value}' — a value is missing")
2391 } else {
2392 format!("invalid width '{value}'")
2393 }
2394 })?;
2395 if columns == 0 {
2396 return Err("--width must be at least 1".into());
2397 }
2398 width = Some(columns);
2399 }
2400 other if other.starts_with('-') && other != "-" => {
2401 return Err(format!("unknown option {other:?}"));
2402 }
2403 other
2404 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2405 {
2406 ansi_word = other == "ansi";
2407 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2408 }
2409 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2410 other => resources.push(other.to_string()),
2411 }
2412 }
2413
2414 if data.format.is_some() {
2417 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2418 return Err(format!(
2419 "--format cannot be combined with --{}",
2420 mode_name(mode)
2421 ));
2422 }
2423 if mode == Mode::Auto && resources.is_empty() && !batch {
2424 resources.push("-".into());
2425 }
2426 }
2427 if let Some(flag) = data
2428 .inspect_only_option(mode == Mode::Capture)
2429 .filter(|_| mode != Mode::Inspect)
2430 {
2431 if flag == "--redact" {
2432 return Err("--redact only has an effect with --inspect or `rich capture`".into());
2433 }
2434 return Err(format!("{flag} only has an effect with --inspect"));
2435 }
2436 if let Some(flag) = data.transform_option() {
2437 if !transforms_apply(mode) {
2438 return Err(format!(
2439 "{flag} only has an effect on {}",
2440 transform_modes(flag)
2441 ));
2442 }
2443 data.check_transforms(mode == Mode::Inspect)?;
2444 }
2445 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2446 return Err(format!("{flag} only has an effect with --diff"));
2447 }
2448 if let Some(flag) = tool_options
2449 .ansi_option()
2450 .filter(|_| mode != Mode::AnsiExplain)
2451 {
2452 return Err(format!("{flag} only has an effect with --ansi-explain"));
2453 }
2454 for (flag, commands) in viewer_options
2455 .given()
2456 .into_iter()
2457 .chain(graph_sources.given())
2458 .chain(chart.given())
2459 .chain(schema_options.given())
2460 .chain(profile_options.given())
2461 {
2462 if !commands.contains(&mode_name(mode)) {
2463 return Err(format!(
2464 "{flag} only has an effect with `rich {}`",
2465 commands.join("` or `rich ")
2466 ));
2467 }
2468 }
2469 if mode == Mode::Capture && resources.is_empty() {
2470 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2471 }
2472 if mode == Mode::View && !paging_explicit {
2473 auto_pager = true;
2474 }
2475 data.validate()?;
2476
2477 if watch && (batch || pager || auto_pager) {
2478 return Err("--watch cannot be combined with --batch or paging".into());
2479 }
2480
2481 if batch {
2485 if mode.accepts_multiple_resources() {
2486 return Err(format!(
2487 "--batch cannot be combined with {}",
2488 if mode == Mode::Diff {
2489 "--diff"
2490 } else {
2491 "--gif"
2492 }
2493 ));
2494 }
2495 if resources.is_empty() {
2496 return Err("--batch needs at least one file, directory or glob".into());
2497 }
2498 if pager || auto_pager {
2500 return Err("--pager cannot be combined with --batch".into());
2501 }
2502 }
2503
2504 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2507 return Err(
2508 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2509 );
2510 }
2511 if mode == Mode::Diff && tool_options.conflicts() {
2514 if resources.len() != 1 {
2515 return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2516 }
2517 if diff_threshold.is_some() {
2518 return Err("--threshold cannot be combined with --conflicts".into());
2519 }
2520 }
2521 if mode == Mode::Image {
2522 if resources.is_empty() {
2523 return Err(
2524 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2525 );
2526 }
2527 if image_mode == ImageMode::None {
2528 return Err(
2529 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2530 .into(),
2531 );
2532 }
2533 }
2534 let quantized = matches!(
2535 image_color.as_deref(),
2536 Some("ansi256" | "ansi16" | "grayscale")
2537 );
2538 if quantized && image_mode == ImageMode::Braille {
2539 return Err(format!(
2540 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2541 image_color.as_deref().unwrap()
2542 ));
2543 }
2544 for (flag, value, neutral) in [
2545 ("--image-dither", image_dither.as_deref(), "none"),
2546 (
2547 "--image-color-distance",
2548 image_color_distance.as_deref(),
2549 "rgb",
2550 ),
2551 ] {
2552 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2553 return Err(format!(
2554 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2555 ));
2556 }
2557 }
2558 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2559 return Err("--image-anchor requires --image-fit cover".into());
2560 }
2561 if (batch_paths.preserve_dirs
2562 || batch_paths.template.is_some()
2563 || batch_paths.input_root.is_some())
2564 && !batch
2565 {
2566 return Err("batch path options require --batch".into());
2567 }
2568 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2569 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2570 }
2571 if dry_run && !batch {
2572 return Err("--dry-run requires --batch".into());
2573 }
2574 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2575 return Err("--jobs greater than 1 requires --batch with a file export".into());
2576 }
2577 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2579 return Err("--image-fit requires --height to define the target rectangle".into());
2580 }
2581 let effective_mode = if mode == Mode::Auto {
2586 detect_mode(resources.first().map(String::as_str))
2587 } else {
2588 mode
2589 };
2590 extensions.validate(
2591 effective_mode == Mode::Gif,
2592 effective_mode.allows_extensions()
2593 && !(mode == Mode::Auto && resources.is_empty())
2594 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2595 )?;
2596 let exporting = export_html.is_some() || export_svg.is_some();
2597 if exporting {
2598 let unsupported = if effective_mode == Mode::Gif {
2599 Some("--gif")
2600 } else if mode == Mode::Auto && resources.is_empty() {
2601 Some("the capability demo")
2602 } else {
2603 None
2604 };
2605 if let Some(what) = unsupported {
2606 return Err(format!("--export-html/--export-svg cannot capture {what}"));
2607 }
2608 }
2609
2610 let text_justify = [
2616 Justify::Left,
2617 Justify::Right,
2618 Justify::Center,
2619 Justify::Full,
2620 ]
2621 .into_iter()
2622 .zip(text_flags)
2623 .find_map(|(justify, given)| given.then_some(justify));
2624 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2627 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2628 let orphans = [
2629 (
2630 "--head/--tail",
2631 source.head.is_some() || source.tail.is_some(),
2632 "--syntax, --csv or --ipynb",
2633 source_mode || effective_mode == Mode::Csv,
2634 ),
2635 (
2636 "--infer",
2637 source.infer,
2638 "--csv",
2639 effective_mode == Mode::Csv,
2640 ),
2641 (
2642 "--line-numbers",
2643 source.line_numbers,
2644 "--syntax or --ipynb",
2645 source_mode,
2646 ),
2647 (
2648 "--guides",
2649 source.guides,
2650 "--syntax or --ipynb",
2651 source_mode,
2652 ),
2653 (
2654 "--lexer",
2655 source.lexer.is_some(),
2656 "--syntax, --ipynb or --rst",
2657 source_mode || effective_mode == Mode::Rst,
2658 ),
2659 (
2660 "--no-wrap",
2661 source.no_wrap,
2662 "--syntax, --ipynb, --print or --rule",
2663 source_mode || print_mode,
2664 ),
2665 (
2666 "--text-left/--text-right/--text-center/--text-full",
2667 text_justify.is_some(),
2668 "--print or --rule",
2669 print_mode,
2670 ),
2671 (
2672 "--rule-style",
2673 rule_style.is_some(),
2674 "--rule",
2675 effective_mode == Mode::Rule,
2676 ),
2677 (
2678 "--rule-char",
2679 rule_char.is_some(),
2680 "--rule",
2681 effective_mode == Mode::Rule,
2682 ),
2683 (
2686 "--soft",
2687 soft,
2688 "a mode other than --jsonl or --log",
2689 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2690 ),
2691 (
2692 "--max-width",
2693 max_width.is_some(),
2694 "a mode other than --jsonl or --log",
2695 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2696 ),
2697 (
2698 "--threshold",
2699 diff_threshold.is_some(),
2700 "--diff",
2701 mode == Mode::Diff,
2702 ),
2703 (
2704 "--image-mode",
2705 image_mode != ImageMode::Auto,
2706 "--diff/--image",
2707 mode == Mode::Diff || mode == Mode::Image,
2708 ),
2709 ("--height", height.is_some(), "--image", mode == Mode::Image),
2710 (
2711 "--image-rotate",
2712 image_rotate.is_some(),
2713 "--image",
2714 mode == Mode::Image,
2715 ),
2716 (
2717 "--image-flip-horizontal",
2718 image_flip_horizontal,
2719 "--image",
2720 mode == Mode::Image,
2721 ),
2722 (
2723 "--image-flip-vertical",
2724 image_flip_vertical,
2725 "--image",
2726 mode == Mode::Image,
2727 ),
2728 (
2729 "--image-grayscale",
2730 image_grayscale,
2731 "--image",
2732 mode == Mode::Image,
2733 ),
2734 (
2735 "--image-color",
2736 image_color.is_some(),
2737 "--image or --gif",
2738 mode == Mode::Image || effective_mode == Mode::Gif,
2739 ),
2740 (
2741 "--image-max-width",
2742 image_max_width.is_some(),
2743 "--image",
2744 mode == Mode::Image,
2745 ),
2746 (
2747 "--image-max-height",
2748 image_max_height.is_some(),
2749 "--image",
2750 mode == Mode::Image,
2751 ),
2752 (
2753 "--image-brightness",
2754 image_brightness.is_some(),
2755 "--image",
2756 mode == Mode::Image,
2757 ),
2758 (
2759 "--image-contrast",
2760 image_contrast.is_some(),
2761 "--image",
2762 mode == Mode::Image,
2763 ),
2764 (
2765 "--image-gamma",
2766 image_gamma.is_some(),
2767 "--image",
2768 mode == Mode::Image,
2769 ),
2770 (
2771 "--image-dither",
2772 image_dither.is_some(),
2773 "--image or --gif",
2774 mode == Mode::Image || effective_mode == Mode::Gif,
2775 ),
2776 (
2777 "--image-color-distance",
2778 image_color_distance.is_some(),
2779 "--image or --gif",
2780 mode == Mode::Image || effective_mode == Mode::Gif,
2781 ),
2782 (
2783 "--image-fit",
2784 image_fit.is_some(),
2785 "--image",
2786 mode == Mode::Image,
2787 ),
2788 (
2789 "--image-background",
2790 image_background.is_some(),
2791 "--image",
2792 mode == Mode::Image,
2793 ),
2794 (
2795 "--loop",
2796 loops.is_some(),
2797 "--gif",
2798 effective_mode == Mode::Gif,
2799 ),
2800 (
2805 "--panel-style",
2806 panel_style.is_some(),
2807 "--panel",
2808 panel.is_some(),
2809 ),
2810 (
2811 "--expand",
2812 expand,
2813 "--panel/--padding",
2814 panel.is_some() || padding.is_some(),
2815 ),
2816 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
2817 ("--watch-cache", watch_cache, "--watch", watch),
2818 (
2819 "--watch-debounce",
2820 watch_debounce.is_some(),
2821 "--watch",
2822 watch,
2823 ),
2824 ("--watch-poll", watch_poll, "--watch", watch),
2825 (
2826 "--watch-exit-on-error",
2827 watch_exit_on_error,
2828 "--watch",
2829 watch,
2830 ),
2831 ];
2832 if let Some((flag, _, needs, _)) = orphans
2833 .iter()
2834 .find(|(_, given, _, mode_present)| *given && !*mode_present)
2835 {
2836 return Err(format!("{flag} only has an effect with {needs}"));
2837 }
2838
2839 let demo = mode == Mode::Auto && resources.is_empty();
2842 if effective_mode.draws_directly() || demo {
2843 let mode_name = if effective_mode == Mode::Gif {
2844 "--gif"
2845 } else if effective_mode == Mode::Image {
2846 "--image"
2847 } else if effective_mode.streams_records() {
2848 mode_name(effective_mode)
2849 } else if demo {
2850 "the capability demo"
2851 } else {
2852 "--diff"
2853 };
2854 let unsupported = [
2855 (
2856 "--pager",
2857 (pager || auto_pager)
2858 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
2859 ),
2860 (
2861 "--export-html/--export-svg",
2862 (export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
2863 ),
2864 ("--width", demo && width.is_some()),
2865 ("--theme-style", demo && !theme_styles.is_empty()),
2866 ("--theme-file", demo && !theme_file_styles.is_empty()),
2867 (
2868 "--hyperlinks",
2869 hyperlinks && (effective_mode.streams_records() || demo),
2870 ),
2871 ("--panel", panel.is_some()),
2872 ("--padding", padding.is_some()),
2873 ("--title", title.is_some()),
2874 ("--caption", caption.is_some()),
2875 ("--style", style.is_some()),
2876 ("--panel-style", panel_style.is_some()),
2877 ("--expand", expand),
2878 ("--left/--center/--right", justify.is_some()),
2879 ];
2880 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
2881 return Err(format!("{flag} cannot be combined with {mode_name}"));
2882 }
2883 }
2884 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
2887 return Err("only one resource may be given (except with --gif or --watch)".into());
2888 }
2889 let sanitize = sanitize.unwrap_or_else(|| {
2894 mode == Mode::View
2895 || mode == Mode::Schema
2896 || (mode == Mode::Diff
2897 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
2898 });
2899 let resource = resources.first().map(|resource| {
2901 if print_mode {
2902 text_arg(resource)
2903 } else {
2904 resource.clone()
2905 }
2906 });
2907 let title = title.map(|title| text_arg(&title));
2908 let caption = caption.map(|caption| text_arg(&caption));
2909 let rule_char = rule_char.map(|characters| text_arg(&characters));
2910
2911 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
2913 Ok(Some(Cli {
2914 mode,
2915 resource,
2916 resources,
2917 loops,
2918 diff_threshold,
2919 image_mode,
2920 image_fit,
2921 image_anchor,
2922 image_background,
2923 image_color,
2924 image_dither,
2925 image_color_distance,
2926 image_rotate,
2927 image_flip_horizontal,
2928 image_flip_vertical,
2929 image_grayscale,
2930 image_max_width,
2931 image_max_height,
2932 image_brightness,
2933 image_contrast,
2934 image_gamma,
2935 log_presentation,
2936 mermaid_backend,
2937 dot_backend,
2938 graph_sources,
2939 chart,
2940 schema: schema_options,
2941 profile: profile_options,
2942 highlighter,
2943 code_theme,
2944 plugins,
2945 micro_project,
2946 theme_styles,
2947 theme_file_styles,
2948 height,
2949 extensions,
2950 width,
2951 justify,
2952 text_justify,
2953 emoji,
2954 soft,
2955 max_width,
2956 rule_style,
2957 rule_char,
2958 force_terminal,
2959 source,
2960 no_color,
2961 export_html,
2962 export_svg,
2963 panel,
2964 padding,
2965 title,
2966 caption,
2967 panel_style,
2968 style,
2969 expand,
2970 pager,
2971 auto_pager,
2972 hyperlinks,
2973 sanitize,
2974 report_format,
2975 watch,
2976 watch_interval,
2977 watch_cache,
2978 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
2979 watch_poll,
2980 watch_exit_on_error,
2981 batch,
2982 progress,
2983 continue_on_error,
2984 jobs,
2985 dry_run,
2986 batch_paths,
2987 worker_args: worker_args(args),
2988 overwrite,
2989 collision,
2990 data,
2991 tools: tool_options,
2992 viewers: viewer_options,
2993 }))
2994}
2995
2996fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3006 use std::io::{Seek, SeekFrom};
3007 let resource = cli.resource.as_deref();
3008 let name = resource.unwrap_or("<stdin>");
3009 let window = |bytes: Vec<u8>| {
3010 let start = usize::try_from(offset)
3011 .unwrap_or(usize::MAX)
3012 .min(bytes.len());
3013 let mut rest = bytes[start..].to_vec();
3014 let more = rest.len() as u64 > max;
3015 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3016 (rest, more)
3017 };
3018 if let Some(url) = resource.filter(|r| is_url(r)) {
3019 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3020 }
3021 if cli.extensions.encoding.is_some() {
3022 return read_resource_limited(
3023 resource,
3024 cli.extensions.encoding,
3025 Some(controls::INSPECT_LIMIT),
3026 )
3027 .map(|text| window(text.into_bytes()))
3028 .map_err(|err| format!("cannot read {name}: {err}"));
3029 }
3030 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3031 let take = max.saturating_add(1);
3033 let mut bytes = Vec::new();
3034 match resource.filter(|r| *r != "-") {
3035 Some(path) if fs_path(path).is_dir() => {
3036 return Err(format!("cannot read {path}: it is a directory"));
3037 }
3038 Some(path) => {
3039 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3040 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3042 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3043 .map_err(error)?;
3044 }
3045 file.take(take).read_to_end(&mut bytes).map_err(error)?;
3046 }
3047 None => {
3048 let mut stdin = std::io::stdin().lock();
3049 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3050 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3051 }
3052 }
3053 let more = bytes.len() as u64 > max;
3054 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3055 Ok((bytes, more))
3056}
3057
3058fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3060 let mut bytes = Vec::new();
3061 match limit {
3062 None => {
3063 let mut reader = reader;
3064 reader.read_to_end(&mut bytes)?;
3065 }
3066 Some(limit) => {
3067 reader
3068 .take(limit.saturating_add(1))
3069 .read_to_end(&mut bytes)?;
3070 if bytes.len() as u64 > limit {
3071 return Err(std::io::Error::other(format!(
3072 "larger than {}, the most this command reads",
3073 controls::size(limit)
3074 )));
3075 }
3076 }
3077 }
3078 Ok(bytes)
3079}
3080
3081fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3094 read_resource_limited(resource, encoding, None)
3095}
3096
3097fn read_resource_limited(
3101 resource: Option<&str>,
3102 encoding: Option<Encoding>,
3103 limit: Option<u64>,
3104) -> std::io::Result<String> {
3105 let content = match resource {
3106 Some(path) if path != "-" => {
3107 let file = &fs_path(path);
3108 if file.is_dir() {
3111 return Err(std::io::Error::new(
3112 std::io::ErrorKind::InvalidInput,
3113 "is a directory, not a file",
3114 ));
3115 }
3116 let bytes = match limit {
3117 None => std::fs::read(file)?,
3118 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3119 };
3120 if let Some(encoding) = encoding {
3121 return encoding.decode(&bytes).map(normalize_newlines);
3122 }
3123 if has_utf16_bom(&bytes) {
3124 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
3125 }
3126 match String::from_utf8(bytes) {
3127 Ok(text) => text,
3128 Err(_) if looks_like_image(path) => {
3133 return Err(std::io::Error::new(
3134 std::io::ErrorKind::InvalidData,
3135 "looks like an image; use `rich --image <path>` to view it, or \
3136 `rich --diff <before> <after>` to compare two images",
3137 ))
3138 }
3139 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3140 }
3141 }
3142 _ => {
3143 if std::io::stdin().is_terminal() {
3144 let eof = if cfg!(windows) {
3145 "Ctrl-Z then Enter"
3146 } else {
3147 "Ctrl-D"
3148 };
3149 eprintln!("rich: reading stdin; finish input with {eof}");
3150 }
3151 let buffer = read_limited(std::io::stdin(), limit)?;
3152 decode_strict(buffer, encoding)?
3153 }
3154 };
3155 Ok(normalize_newlines(if encoding.is_some() {
3156 content
3157 } else {
3158 strip_bom(content)
3159 }))
3160}
3161
3162fn sanitize_json_value(value: &mut serde_json::Value) {
3163 match value {
3164 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3165 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3166 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3167 _ => {}
3168 }
3169}
3170
3171fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3172 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3173 sanitize_json_value(&mut value);
3174 serde_json::to_string(&value)
3175}
3176
3177fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3179 if let Some(encoding) = encoding {
3180 return encoding.decode(&bytes);
3181 }
3182 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3183 std::io::ErrorKind::InvalidData,
3184 if has_utf16_bom(err.as_bytes()) {
3185 "input has a UTF-16 BOM; use --encoding utf-16"
3186 } else {
3187 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3188 },
3189 ))
3190}
3191
3192fn normalize_newlines(text: String) -> String {
3201 const CR: char = '\r';
3202 const LF: char = '\n';
3203 if !text.contains(CR) {
3204 return text;
3205 }
3206 let mut out = String::with_capacity(text.len());
3207 let mut chars = text.chars().peekable();
3208 while let Some(c) = chars.next() {
3209 if c == CR {
3210 if chars.peek() == Some(&LF) {
3213 chars.next();
3214 }
3215 out.push(LF);
3216 } else {
3217 out.push(c);
3218 }
3219 }
3220 out
3221}
3222
3223fn looks_like_image(path: &str) -> bool {
3233 std::path::Path::new(path)
3234 .extension()
3235 .and_then(|e| e.to_str())
3236 .map(|e| e.to_ascii_lowercase())
3237 .is_some_and(|e| {
3238 matches!(
3239 e.as_str(),
3240 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3241 )
3242 })
3243}
3244
3245fn strip_bom(text: String) -> String {
3246 match text.strip_prefix('\u{feff}') {
3247 Some(rest) => rest.to_string(),
3248 None => text,
3249 }
3250}
3251
3252fn is_url(resource: &str) -> bool {
3255 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3256 head.starts_with("http://") || head.starts_with("https://")
3257}
3258
3259fn resource_ext(resource: &str) -> Option<String> {
3263 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3264 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3265 basename
3266 .rsplit_once('.')
3267 .map(|(_, ext)| ext.to_ascii_lowercase())
3268 .filter(|ext| !ext.is_empty())
3269}
3270
3271#[cfg(feature = "fetch")]
3274const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3275
3276#[cfg(feature = "fetch")]
3284fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3285 use std::time::Duration;
3286 let mut response = ureq::get(url)
3287 .config()
3288 .timeout_global(Some(Duration::from_secs(30)))
3289 .http_status_as_error(false)
3292 .build()
3293 .call()
3294 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3295 let content_type = response
3296 .headers()
3297 .get("content-type")
3298 .and_then(|value| value.to_str().ok())
3299 .map(str::to_string);
3300
3301 let mut buffer = Vec::new();
3307 response
3308 .body_mut()
3309 .with_config()
3310 .reader()
3311 .take(MAX_BODY_BYTES + 1)
3312 .read_to_end(&mut buffer)
3313 .map_err(|err| format!("cannot read {url}: {err}"))?;
3314 if buffer.len() as u64 > MAX_BODY_BYTES {
3315 return Err(format!(
3316 "{url} exceeds the {} MiB limit",
3317 MAX_BODY_BYTES / (1024 * 1024)
3318 ));
3319 }
3320 let body =
3321 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3322 Ok((body, content_type))
3323}
3324
3325#[cfg(not(feature = "fetch"))]
3327fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3328 Err(format!(
3329 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3330 ))
3331}
3332
3333fn content_type_mode(content_type: &str) -> Option<Mode> {
3337 let mime = mime_of(content_type);
3338 match mime.as_str() {
3339 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3340 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3341 "application/json" | "text/json" => Some(Mode::Json),
3342 "text/csv" => Some(Mode::Csv),
3343 _ => None,
3344 }
3345}
3346
3347fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3351 let mime = mime_of(content_type);
3352 Some(match mime.as_str() {
3353 "text/html" | "application/xhtml+xml" => "html",
3354 "text/css" => "css",
3355 "application/javascript" | "text/javascript" => "javascript",
3356 "application/xml" | "text/xml" => "xml",
3357 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3358 "text/x-rust" => "rust",
3359 "text/x-c" | "text/x-csrc" => "c",
3360 "application/x-sh" | "text/x-shellscript" => "bash",
3361 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3362 "application/toml" | "text/x-toml" => "toml",
3363 _ => return None,
3364 })
3365}
3366
3367fn is_uninformative_ext(ext: &str) -> bool {
3370 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3371}
3372
3373fn mime_of(content_type: &str) -> String {
3376 content_type
3377 .split(';')
3378 .next()
3379 .unwrap_or("")
3380 .trim()
3381 .to_ascii_lowercase()
3382}
3383
3384fn transforms_apply(mode: Mode) -> bool {
3388 matches!(
3389 mode,
3390 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3391 )
3392}
3393
3394fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3398 if let Some(flag) = cli
3399 .data
3400 .transform_option()
3401 .filter(|_| !transforms_apply(mode))
3402 {
3403 return Some(transforms_misplaced(flag, mode));
3404 }
3405 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3406 .then(|| cli.data.check_transforms(true).unwrap_err())
3407}
3408
3409fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3410 format!(
3411 "{flag} only has an effect on {}, not {}",
3412 transform_modes(flag),
3413 mode_name(mode)
3414 )
3415}
3416
3417fn transform_modes(flag: &str) -> &'static str {
3419 if flag == "--transform" {
3420 "text, --print or --syntax"
3421 } else {
3422 "text, --print, --syntax or --inspect"
3423 }
3424}
3425
3426fn detect_mode(resource: Option<&str>) -> Mode {
3427 match resource.and_then(resource_ext).as_deref() {
3428 Some("md") | Some("markdown") => Mode::Markdown,
3429 Some("json") => Mode::Json,
3430 Some("csv") | Some("tsv") => Mode::Csv,
3431 Some("ipynb") => Mode::Ipynb,
3432 Some("rst") => Mode::Rst,
3433 Some("gif") => Mode::Gif,
3434 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3435 Some("dot") | Some("gv") => Mode::Dot,
3436 Some(_) => Mode::Syntax,
3444 None => Mode::Auto,
3448 }
3449}
3450
3451struct ForceWidth {
3459 child: Box<dyn Renderable>,
3460 width: usize,
3461}
3462
3463impl Renderable for ForceWidth {
3464 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3465 self.child
3466 .rich_render(console, &options.update_width(self.width))
3467 }
3468
3469 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3470 Measurement::new(self.width, self.width)
3471 }
3472}
3473
3474struct Aligned {
3484 child: Box<dyn Renderable>,
3485 width: usize,
3491 justify: Justify,
3492}
3493
3494impl Renderable for Aligned {
3495 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3496 let width = self.width.clamp(1, options.max_width);
3497 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3507 let widths: Vec<usize> = lines
3508 .iter()
3509 .map(|line| line.iter().map(Segment::cell_length).sum())
3510 .collect();
3511 let shape = widths.iter().copied().max().unwrap_or(0);
3515
3516 let excess = options.max_width.saturating_sub(shape);
3517 let (left, right) = match self.justify {
3518 Justify::Right => (excess, 0),
3519 Justify::Center => (excess / 2, excess - excess / 2),
3520 _ => (0, excess),
3521 };
3522 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3523
3524 let mut segments = Vec::new();
3525 let last = lines.len().saturating_sub(1);
3526 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3527 if left > 0 {
3528 segments.push(blank(left));
3529 }
3530 segments.extend(line);
3531 let pad = shape - line_width + right;
3532 if pad > 0 {
3533 segments.push(blank(pad));
3534 }
3535 if index != last {
3536 segments.push(Segment::line());
3537 }
3538 }
3539 segments
3540 }
3541}
3542
3543fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3555 console
3556 .render_lines(renderable, &console.options(), false)
3557 .iter()
3558 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3559 .max()
3560 .unwrap_or(0)
3561}
3562
3563fn micro_pending() -> bool {
3568 #[cfg(feature = "art")]
3569 return micro::drawing_pending();
3570 #[cfg(not(feature = "art"))]
3571 false
3572}
3573
3574fn validate_labels(cli: &Cli) -> Result<(), String> {
3575 for label in cli.title.iter().chain(cli.caption.iter()) {
3576 Text::from_markup(label).map_err(|err| err.to_string())?;
3577 }
3578 Ok(())
3579}
3580
3581fn decorate_and_emit(
3587 cli: &Cli,
3588 console: &Console,
3589 export: &Export,
3590 renderable: Box<dyn Renderable>,
3591 fit: Option<usize>,
3592) -> ExitCode {
3593 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3594}
3595
3596fn decorate_and_emit_with(
3599 cli: &Cli,
3600 console: &Console,
3601 export: &Export,
3602 renderable: Box<dyn Renderable>,
3603 fit: Option<usize>,
3604 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3605) -> ExitCode {
3606 if cli.panel.is_some() {
3607 if let Err(err) = validate_labels(cli) {
3608 return fail(cli, ExitClass::Data, err);
3609 }
3610 }
3611 let max_width = console.width();
3612 let expand = cli.expand || cli.width.is_some();
3614 let mut renderable = renderable;
3615 let mut fit = fit;
3616
3617 if let Some((top, right, bottom, left)) = cli.padding {
3623 let padded: Box<dyn Renderable> =
3624 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3625 fit = fit.map(|width| (width + left + right).min(max_width));
3628 renderable = if expand {
3629 padded
3630 } else {
3631 Box::new(Constrain::new(padded, fit))
3632 };
3633 }
3634
3635 if let Some(box_set) = cli.panel {
3636 let mut panel = Panel::new(renderable).box_set(box_set);
3637 #[cfg(feature = "art")]
3640 let micro_label = |label: &Option<String>| {
3641 label
3642 .as_deref()
3643 .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3644 };
3645 #[cfg(not(feature = "art"))]
3646 let micro_label = |_: &Option<String>| None::<Text>;
3647 let micro_title = micro_label(&cli.title);
3648 if let Some(title) = micro_title.clone() {
3649 panel = panel.title_as_text(title);
3650 } else if let Some(title) = cli.title.clone() {
3651 panel = panel.title(title);
3652 }
3653 if let Some(caption) = micro_label(&cli.caption) {
3654 panel = panel.subtitle_as_text(caption);
3655 } else if let Some(caption) = cli.caption.clone() {
3656 panel = panel.subtitle(caption);
3657 }
3658 if let Some(style) = cli.panel_style.clone() {
3659 panel = panel.border_style(style);
3660 }
3661 let inner = max_width.saturating_sub(4);
3670 let child = fit.unwrap_or(inner).min(inner);
3671 let title_width = cli
3672 .title
3673 .as_deref()
3674 .map(|title| {
3675 let mut title = match µ_title {
3678 Some(title) => {
3679 let mut flat = title.blank_copy();
3680 flat.append(&title.plain().replace('\n', " "), None);
3681 flat
3682 }
3683 None => {
3684 let title = rich::emoji::replace(title);
3685 Text::from_markup(&title.replace('\n', " "))
3686 .expect("title markup validated before rendering")
3687 }
3688 };
3689 title.expand_tabs(8);
3690 cell_len(title.plain()) + 2
3691 })
3692 .unwrap_or(0);
3693 fit = Some(child.max(title_width) + 4);
3694 let panel: Box<dyn Renderable> = Box::new(panel);
3695 renderable = if expand {
3696 panel
3697 } else {
3698 Box::new(Constrain::new(panel, fit))
3699 };
3700 }
3701
3702 if let Some(style) = cli.style.clone() {
3705 renderable = Box::new(Styled::new(renderable, style));
3706 }
3707
3708 if let Some(width) = cli.width.filter(|_| !cli.pager) {
3710 renderable = Box::new(ForceWidth {
3711 child: renderable,
3712 width,
3713 });
3714 fit = Some(width);
3715 }
3716
3717 if let Some(justify) = cli.justify {
3718 renderable = Box::new(Aligned {
3719 child: renderable,
3720 width: fit.unwrap_or(max_width),
3721 justify,
3722 });
3723 }
3724
3725 #[cfg(feature = "art")]
3730 if let Some(registry) =
3731 micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
3732 {
3733 renderable = micro::drawn(console, registry, renderable);
3734 }
3735
3736 let mut options = console.options();
3739 if let Some(max_width) = cli.max_width {
3740 options.max_width = max_width.min(options.max_width);
3741 }
3742 let shown = if cli.soft {
3743 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
3744 if !segments.is_empty() {
3745 segments.push(Segment::line());
3746 }
3747 emit_segments(console, export, segments)
3748 } else {
3749 emit(console, export, |c| {
3750 c.print_with(renderable.as_ref(), &options)
3751 })
3752 };
3753 finish(cli, shown)
3754}
3755
3756fn soft_wrap_segments(
3762 console: &Console,
3763 renderable: &dyn Renderable,
3764 options: &ConsoleOptions,
3765) -> Vec<Segment> {
3766 let mut options = options.clone();
3767 options.no_wrap.get_or_insert(true);
3768 options
3769 .overflow
3770 .get_or_insert(rich::console::Overflow::Ignore);
3771 let joined = renderable.printed_text();
3772 let renderable: &dyn Renderable = match &joined {
3773 Some(text) => text,
3774 None => renderable,
3775 };
3776 if renderable.fit_to_measurement() {
3777 let measurement = renderable.measure(console, &options);
3778 options.max_width = measurement.maximum.min(options.max_width).max(1);
3779 }
3780 console.render(renderable, Some(&options))
3781}
3782
3783fn run_cli(cli: Cli) -> ExitCode {
3784 if cli.watch {
3785 return run_watch(cli);
3786 }
3787 run_once(cli)
3788}
3789
3790fn run_watch(mut cli: Cli) -> ExitCode {
3791 let Some(resource) = cli.resource.clone() else {
3792 return fail(
3793 &cli,
3794 ExitClass::Usage,
3795 "--watch requires a file path or URL",
3796 );
3797 };
3798 let several = cli.resources.len() > 1;
3799 for resource in &cli.resources {
3800 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
3801 return fail(
3802 &cli,
3803 ExitClass::Usage,
3804 "--watch requires a local file or URL, not stdin or literal markup",
3805 );
3806 }
3807 if several && is_url(resource) {
3808 return fail(
3809 &cli,
3810 ExitClass::Usage,
3811 "--watch with several resources requires local files; watch a URL on its own",
3812 );
3813 }
3814 let effective_mode = if cli.mode == Mode::Auto {
3815 detect_mode(Some(resource))
3816 } else {
3817 cli.mode
3818 };
3819 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
3820 return fail(
3821 &cli,
3822 ExitClass::Usage,
3823 "--watch requires a resource-backed render mode",
3824 );
3825 }
3826 }
3827 if several && (cli.export_html.is_some() || cli.export_svg.is_some()) {
3828 return fail(
3829 &cli,
3830 ExitClass::Usage,
3831 "--export-html/--export-svg cannot be combined with watching several files",
3832 );
3833 }
3834 if !std::io::stdout().is_terminal() {
3838 cli.watch = false;
3839 if !several {
3840 return run_once(cli);
3841 }
3842 let mut first_failure = None;
3843 for resource in cli.resources.clone() {
3844 let mut single = cli.clone();
3845 single.resource = Some(resource.clone());
3846 single.resources = vec![resource];
3847 let status = run_once(single);
3848 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
3849 first_failure = Some(status);
3850 }
3851 }
3852 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
3853 }
3854 watch::start_watching();
3856 let resource = resource.as_str();
3857 if !is_url(resource) {
3858 return watch::watch_files(cli);
3859 }
3860 if is_url(resource) {
3862 #[cfg(not(feature = "fetch"))]
3863 {
3864 cli.watch = false;
3865 return run_once(cli);
3866 }
3867 }
3868
3869 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
3870 let mut previous = None;
3871 loop {
3872 #[cfg(feature = "fetch")]
3873 let cached = if is_url(resource) && cli.watch_cache {
3874 Some(fetch_url(resource, cli.extensions.encoding))
3875 } else {
3876 None
3877 };
3878 #[cfg(not(feature = "fetch"))]
3879 let cached: Option<Result<(String, Option<String>), String>> = None;
3880 let fingerprint = match cached.as_ref() {
3881 Some(Ok((body, content_type))) => {
3882 use std::hash::{Hash, Hasher};
3883 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3884 body.hash(&mut hasher);
3885 content_type.hash(&mut hasher);
3886 format!("url:{:x}", hasher.finish())
3887 }
3888 Some(Err(error)) => format!("url-error:{error}"),
3889 None => watch_fingerprint(resource, false, cli.extensions.encoding),
3890 };
3891 let changed = previous.as_ref() != Some(&fingerprint);
3892 if changed || (is_url(resource) && !cli.watch_cache) {
3893 previous = Some(fingerprint);
3894 let console = Console::new();
3897 console.clear();
3898 console.control(&Control::home());
3899 let _ = std::io::stdout().flush();
3900 let mut iteration = cli.clone();
3901 iteration.watch = false;
3902 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
3903 if status != ExitClass::Success.exit_code() {
3904 eprintln!("rich: watch will retry after the next change");
3907 }
3908 }
3909 std::thread::sleep(interval);
3910 }
3911}
3912
3913fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
3914 if is_url(resource) {
3915 #[cfg(feature = "fetch")]
3916 if cache_url {
3917 return match fetch_url(resource, encoding) {
3918 Ok((body, content_type)) => {
3919 use std::hash::{Hash, Hasher};
3920 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3921 body.hash(&mut hasher);
3922 content_type.hash(&mut hasher);
3923 format!("url:{:x}", hasher.finish())
3924 }
3925 Err(error) => format!("url-error:{error}"),
3926 };
3927 }
3928 #[cfg(not(feature = "fetch"))]
3929 let _ = (cache_url, encoding);
3930 return "url".to_string();
3931 }
3932 match std::fs::metadata(fs_path(resource)) {
3933 Ok(metadata) if metadata.is_file() => {
3934 use std::hash::Hasher;
3935 let mut file = match std::fs::File::open(fs_path(resource)) {
3939 Ok(file) => file,
3940 Err(error) => return format!("file-error:{error}"),
3941 };
3942 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3943 let mut buffer = [0u8; 64 * 1024];
3944 loop {
3945 match file.read(&mut buffer) {
3946 Ok(0) => break,
3947 Ok(count) => hasher.write(&buffer[..count]),
3948 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3949 Err(error) => return format!("file-error:{error}"),
3950 }
3951 }
3952 format!("file:{:x}", hasher.finish())
3953 }
3954 Ok(_) => "not-file".to_string(),
3955 Err(_) => "missing".to_string(),
3956 }
3957}
3958
3959fn run_once(cli: Cli) -> ExitCode {
3960 run_once_with_fetch(cli, None)
3961}
3962
3963fn cli_theme(cli: &Cli) -> rich::Theme {
3966 let mut theme = rich::Theme::default_theme();
3967 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
3968 theme.insert(name.clone(), style.clone());
3969 }
3970 theme
3971}
3972
3973impl Cli {
3974 fn themed(&self) -> bool {
3976 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
3977 }
3978}
3979
3980fn read_theme_file(
3988 path: &str,
3989 label: &str,
3990) -> Result<std::collections::BTreeMap<String, Style>, String> {
3991 let limit = controls::THEME_FILE_LIMIT;
3992 let path = &fs_path(path);
3994 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
3996 if !metadata.is_file() {
3997 return Err(format!("{label}: not a regular file"));
3998 }
3999 let too_large = || {
4000 format!(
4001 "{label}: larger than {}, the most read from a theme file",
4002 controls::size(limit)
4003 )
4004 };
4005 if metadata.len() > limit {
4006 return Err(too_large());
4007 }
4008 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4009 let mut bytes = Vec::new();
4010 file.take(limit + 1)
4011 .read_to_end(&mut bytes)
4012 .map_err(|err| format!("{label}: {err}"))?;
4013 if bytes.len() as u64 > limit {
4014 return Err(too_large());
4015 }
4016 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4017 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4018 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4020 _ => match bad_style_line(&text) {
4021 Some(line) => format!("{label}: line {line}: {err}"),
4022 None => format!("{label}: {err}"),
4023 },
4024 })?;
4025 Ok(theme
4026 .names()
4027 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4028 .collect())
4029}
4030
4031fn bad_style_line(text: &str) -> Option<usize> {
4034 let mut in_styles = false;
4035 for (index, line) in text.lines().enumerate() {
4036 let trimmed = line.trim();
4037 if trimmed.starts_with('[') {
4038 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4039 continue;
4040 }
4041 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4042 continue;
4043 }
4044 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4045 continue;
4046 };
4047 if Style::parse(value.trim()).is_err() {
4048 return Some(index + 1);
4049 }
4050 }
4051 None
4052}
4053
4054fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4055 if cli.sanitize {
4056 cli.title = cli
4057 .title
4058 .take()
4059 .map(|title| sanitize_terminal_controls(&title));
4060 cli.caption = cli
4061 .caption
4062 .take()
4063 .map(|caption| sanitize_terminal_controls(&caption));
4064 }
4065
4066 if cli.batch {
4067 return run_batch(&cli);
4068 }
4069
4070 if cli.mode == Mode::Auto && cli.resource.is_none() {
4072 run_demo(cli.no_color, std::time::Duration::ZERO);
4073 return success(&cli);
4074 }
4075
4076 let mut mode = match cli.mode {
4077 Mode::Auto => detect_mode(cli.resource.as_deref()),
4078 other => other,
4079 };
4080 if let Some(message) = transform_misplaced_in(&cli, mode) {
4081 return fail(&cli, ExitClass::Usage, message);
4082 }
4083
4084 let mut prefetched = prefetched;
4088 let mut view_as = None;
4089 let mut view_bytes = Vec::new();
4090 if mode == Mode::View {
4091 let ext = cli
4092 .resource
4093 .as_deref()
4094 .filter(|r| *r != "-")
4095 .and_then(resource_ext);
4096 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4099 if !matches!(
4100 by_name,
4101 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4102 ) {
4103 let limit = if by_name == viewers::ViewAs::Notebook {
4106 controls::INSPECT_LIMIT
4107 } else {
4108 controls::VIEW_LIMIT
4109 };
4110 let more;
4111 (view_bytes, more) = match read_window(&cli, 0, limit) {
4112 Ok(read) => read,
4113 Err(err) => return fail(&cli, ExitClass::Input, err),
4114 };
4115 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4116 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4117 if kind == viewers::ViewAs::Binary {
4118 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4119 view_bytes.truncate(controls::BYTES_LIMIT as usize);
4120 viewers::notice(
4121 name,
4122 &controls::size(controls::BYTES_LIMIT),
4123 "use rich hex with --offset and --length for the rest",
4124 );
4125 }
4126 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4127 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4128 {
4129 view_bytes.truncate(end);
4131 viewers::notice(
4132 name,
4133 &format!("{} lines", controls::LINE_LIMIT),
4134 "use a pager or rich --syntax for the whole file",
4135 );
4136 } else if more {
4137 viewers::notice(
4138 name,
4139 &controls::size(limit),
4140 "use a pager or rich --syntax for the whole file",
4141 );
4142 }
4143 }
4144 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4145 mode = match kind {
4146 viewers::ViewAs::Markdown => Mode::Markdown,
4147 viewers::ViewAs::Csv => Mode::Csv,
4148 viewers::ViewAs::Notebook => Mode::Ipynb,
4149 viewers::ViewAs::Image => Mode::Image,
4150 viewers::ViewAs::Gif => Mode::Gif,
4151 viewers::ViewAs::Log => Mode::Log,
4152 _ => Mode::View,
4153 };
4154 let text = String::from_utf8_lossy(&view_bytes).into_owned();
4155 prefetched = Some((text, None));
4156 view_as = Some(kind);
4157 }
4158
4159 let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
4164 if cli.themed() {
4165 builder = builder.theme(cli_theme(&cli));
4166 }
4167 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4170 builder = builder.force_terminal(terminal == "1");
4171 }
4172 if cli.force_terminal {
4173 builder = builder.force_terminal(true);
4174 }
4175 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4176 builder = builder.width(width);
4177 }
4178 if let Some(width) = watch::capture_width() {
4181 builder = builder.width(width);
4182 }
4183 let mut console = builder.build();
4184 render_target::attach(&mut console);
4185 console.install_extensions();
4186 plugins::install_highlighters(&mut console);
4187 apply_code_highlighting(&mut console, &cli);
4188
4189 if mode == Mode::Rule {
4190 if let Some(title) = &cli.resource {
4191 if let Err(err) = Text::from_markup(title) {
4192 return fail(&cli, ExitClass::Data, err.to_string());
4193 }
4194 }
4195 }
4196
4197 let literal = mode == Mode::Rule
4203 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4204 let mut svg_title = cli
4205 .resource
4206 .as_deref()
4207 .filter(|r| *r != "-" && !literal)
4208 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4209 .unwrap_or_else(|| "rich".to_string());
4210 if cli.sanitize {
4211 svg_title = sanitize_terminal_controls(&svg_title);
4212 }
4213 #[allow(unused_mut)]
4214 let mut export = Export {
4215 html_path: cli.export_html.as_deref(),
4216 svg_path: cli.export_svg.as_deref(),
4217 svg_title: &svg_title,
4218 pager: cli.pager,
4219 auto_pager: cli.auto_pager,
4220 };
4221
4222 if mode == Mode::Gif {
4225 return play_gifs(&cli, &console);
4226 }
4227
4228 if mode == Mode::Diff {
4231 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4232 if !images {
4233 return run_text_diff(&cli, &console, &export);
4234 }
4235 if let Some(flag) = cli.tools.diff_option() {
4236 return fail(
4237 &cli,
4238 ExitClass::Usage,
4239 format!("{flag} applies to text diffs, not images"),
4240 );
4241 }
4242 #[cfg(feature = "art")]
4243 return run_diff(&cli, &console, &export);
4244 #[cfg(not(feature = "art"))]
4245 return fail(
4246 &cli,
4247 ExitClass::Usage,
4248 "this build has no image support (rebuild with the `art` feature)",
4249 );
4250 }
4251
4252 #[cfg(feature = "art")]
4255 if mode == Mode::Image {
4256 return run_image(&cli, &console, &export);
4257 }
4258 #[cfg(not(feature = "art"))]
4259 if mode == Mode::Image {
4260 return fail(
4261 &cli,
4262 ExitClass::Usage,
4263 "this build has no image support (rebuild with the `art` feature)",
4264 );
4265 }
4266
4267 if mode.streams_records() {
4268 return run_json_lines(&cli, &console, mode == Mode::Log);
4269 }
4270
4271 if mode == Mode::Rule {
4275 let mut rule = match cli.resource.as_deref() {
4276 Some(title) if title != "-" && cli.sanitize => {
4277 Rule::new(sanitize_terminal_controls(title))
4278 }
4279 Some(title) if title != "-" => Rule::new(title),
4280 _ => Rule::line(),
4281 };
4282 let style = cli
4287 .rule_style
4288 .clone()
4289 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4290 rule = rule.style(style);
4291 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4292 if cli.sanitize {
4295 rule = rule.characters(sanitize_terminal_controls(characters));
4296 } else {
4297 rule = rule.characters(characters);
4298 }
4299 }
4300 match cli.text_justify {
4301 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4302 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4303 _ => {}
4304 }
4305 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4308 }
4309
4310 if mode == Mode::Capture {
4312 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4313 Ok(redactor) => redactor,
4314 Err(err) => return fail(&cli, ExitClass::Usage, err),
4315 };
4316 let width = cli
4319 .width
4320 .unwrap_or_else(|| console.width())
4321 .saturating_sub(4)
4322 .max(1);
4323 let cast = match viewers::cast_path(&cli.viewers) {
4326 Some(path) => match std::fs::File::create(path) {
4327 Ok(file) => Some((path, file)),
4328 Err(err) => {
4329 return fail(
4330 &cli,
4331 ExitClass::Input,
4332 format!("cannot write {path}: {err}"),
4333 )
4334 }
4335 },
4336 None => None,
4337 };
4338 let mut captured = match viewers::capture(&cli.resources, width) {
4339 Ok(captured) => captured,
4340 Err(err) => return fail(&cli, ExitClass::Input, err),
4341 };
4342 if let Some(notice) = &captured.notice {
4343 eprintln!("rich: capture: {notice}");
4344 }
4345 if let Some(redactor) = &redactor {
4347 captured.redact(redactor);
4348 }
4349 if cli.sanitize {
4350 captured.sanitize();
4351 }
4352 if let Some((path, mut file)) = cast {
4353 let cast = viewers::asciicast(&captured, width, console.height());
4354 if let Err(err) = file.write_all(cast.as_bytes()) {
4355 return fail(
4356 &cli,
4357 ExitClass::Input,
4358 format!("cannot write {path}: {err}"),
4359 );
4360 }
4361 }
4362 let view = viewers::capture_view(&captured);
4363 let fit = view.measure(&console, &console.options()).maximum;
4364 let code = captured.exit_code();
4367 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4368 if code == 0 || shown.is_err() {
4369 return emit_exit_code(cli, shown);
4370 }
4371 if cli.report_format == ReportFormat::Json {
4373 eprintln!("{}", viewers::capture_report(&captured));
4374 }
4375 ExitCode::from(code)
4376 });
4377 }
4378 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4379 return fail(
4380 &cli,
4381 ExitClass::Usage,
4382 "this build has no Mermaid support (the mermaid feature)",
4383 );
4384 }
4385 if mode == Mode::Env {
4386 let view = viewers::env(&cli.viewers, &cli.resources);
4387 let fit = view.measure(&console, &console.options()).maximum;
4388 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4389 }
4390 if mode == Mode::Profile {
4391 let profile = match profile::profile(&cli) {
4392 Ok(profile) => profile,
4393 Err((class, err)) => return fail(&cli, class, err),
4394 };
4395 if cli.report_format == ReportFormat::Json {
4397 let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4398 authoring::out(&format!("{json}\n"));
4399 return ExitClass::Success.exit_code();
4400 }
4401 let view: Box<dyn Renderable> = Box::new(profile);
4402 let fit = view.measure(&console, &console.options()).maximum;
4403 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4404 }
4405 if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4406 let view = match mode {
4407 Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4408 Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4409 _ => chart::chart(&cli).map(|view| (view, None)),
4410 };
4411 let (view, gate) = match view {
4412 Ok(view) => view,
4413 Err((class, err)) => return fail(&cli, class, err),
4414 };
4415 let fit = view.measure(&console, &console.options()).maximum;
4416 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4419 match gate {
4420 Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4421 _ => emit_exit_code(cli, shown),
4422 }
4423 });
4424 }
4425 if matches!(mode, Mode::Hex | Mode::Unicode) {
4426 let hex = mode == Mode::Hex;
4429 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4430 let length = cli.viewers.length().filter(|_| hex);
4431 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4432 let (bytes, more) = match read_window(&cli, offset, max) {
4433 Ok(read) => read,
4434 Err(err) => return fail(&cli, ExitClass::Input, err),
4435 };
4436 if more && length.is_none() {
4437 let hint = if hex {
4438 "use --offset and --length for the rest"
4439 } else {
4440 "pass a shorter input"
4441 };
4442 viewers::notice(
4443 cli.resource.as_deref().unwrap_or("<stdin>"),
4444 &controls::size(controls::BYTES_LIMIT),
4445 hint,
4446 );
4447 }
4448 let view = if hex {
4449 viewers::hex(&cli.viewers, bytes, offset)
4450 } else {
4451 viewers::unicode(&cli.viewers, &bytes)
4452 };
4453 let view = match view {
4454 Ok(view) => view,
4455 Err(err) => return fail(&cli, ExitClass::Usage, err),
4456 };
4457 let fit = view.measure(&console, &console.options()).maximum;
4458 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4459 }
4460
4461 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4465 let (mut content, content_type) = if let Some(fetched) = prefetched {
4466 fetched
4467 } else if resource_is_url {
4468 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4469 Ok(fetched) => fetched,
4470 Err(err) => {
4471 return fail(&cli, ExitClass::Input, err);
4472 }
4473 }
4474 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4475 (cli.resource.clone().unwrap(), None)
4476 } else {
4477 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4479 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4480 Ok(content) => (content, None),
4481 Err(err) => {
4482 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4483 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4484 }
4485 }
4486 };
4487 if cli.sanitize && mode != Mode::AnsiExplain {
4490 content = sanitize_terminal_controls(&content);
4491 }
4492
4493 let mut routed_language = None;
4496 let mut routed = false;
4497 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4498 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4499 routed = true;
4500 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4501 inspect::Route::Json => Mode::Json,
4502 inspect::Route::Syntax(lexer) => {
4503 routed_language = Some(lexer);
4504 Mode::Syntax
4505 }
4506 inspect::Route::Text => Mode::Auto,
4507 };
4508 }
4509 }
4510
4511 if resource_is_url && mode == Mode::Auto && !routed {
4515 mode = content_type
4516 .as_deref()
4517 .and_then(content_type_mode)
4518 .unwrap_or(Mode::Syntax);
4519 }
4520
4521 if mode == Mode::Auto && cli.source.wants_syntax() {
4524 mode = Mode::Syntax;
4525 }
4526
4527 let json_content = if mode == Mode::Json && cli.sanitize {
4529 match sanitize_json_source(content.trim()) {
4530 Ok(content) => Some(content),
4531 Err(err) => {
4532 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4533 }
4534 }
4535 } else {
4536 None
4537 };
4538
4539 let json = if mode == Mode::Json {
4540 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4541 match Json::new(source) {
4542 Ok(json) => Some(json),
4543 Err(err) => {
4544 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4545 }
4546 }
4547 } else {
4548 None
4549 };
4550
4551 if mode == Mode::Ipynb {
4556 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4557 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4558 if cli.sanitize {
4559 sanitize_json_value(&mut value);
4560 }
4561 value
4562 }
4563 Ok(_) => {
4564 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4565 }
4566 Err(err) => {
4567 return fail(
4568 &cli,
4569 ExitClass::Data,
4570 format!("invalid notebook JSON: {err}"),
4571 );
4572 }
4573 };
4574 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4575 Ok(renderable) => renderable,
4576 Err(err) => return fail(&cli, ExitClass::Usage, err),
4577 };
4578 let fit = renderable.measure(&console, &console.options()).maximum;
4579 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4580 }
4581
4582 if mode == Mode::Print {
4586 if let Err(err) = console.try_build_text(&content) {
4587 return fail(&cli, ExitClass::Data, err.to_string());
4588 }
4589 }
4590
4591 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4596 .filter(|ext| !is_uninformative_ext(ext));
4597 let language = routed_language
4598 .map(str::to_string)
4599 .or(extension)
4600 .or_else(|| {
4601 content_type
4602 .as_deref()
4603 .and_then(content_type_lexer)
4604 .map(str::to_string)
4605 })
4606 .unwrap_or_default();
4607
4608 if let Some(message) = transform_misplaced_in(&cli, mode) {
4610 return fail(&cli, ExitClass::Usage, message);
4611 }
4612
4613 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4616 Mode::Markdown => {
4619 #[cfg(feature = "art")]
4621 let micro = micro::markdown(&console, &content, cli.micro_project);
4622 #[cfg(feature = "art")]
4623 let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
4624 #[cfg(not(feature = "art"))]
4625 let source = content.as_str();
4626 let mut markdown = build_markdown(source, cli.hyperlinks);
4627 if let Some(fences) = plugin_registry_with(
4630 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4631 cli.dot_backend != Some(sources::DotBackend::Off),
4632 )
4633 .fences()
4634 {
4635 markdown = markdown.fence_renderer(fences);
4636 }
4637 #[cfg(feature = "art")]
4638 let view: Box<dyn Renderable> = match micro {
4639 Some(micro) => Box::new(micro.view(markdown)),
4640 None => Box::new(markdown),
4641 };
4642 #[cfg(not(feature = "art"))]
4643 let view: Box<dyn Renderable> = Box::new(markdown);
4644 (view, None)
4645 }
4646 Mode::Rst => {
4650 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4651 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4652 (Box::new(document), None)
4653 }
4654 #[cfg(feature = "mermaid")]
4655 Mode::Mermaid => {
4656 let default = if cfg!(feature = "mmdc") {
4658 MermaidBackend::Mmdc
4659 } else {
4660 MermaidBackend::Text
4661 };
4662 let backend = match cli.mermaid_backend.unwrap_or(default) {
4663 MermaidBackend::Off => MermaidBackend::Text,
4664 chosen => chosen,
4665 };
4666 let diagram =
4667 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4668 (Box::new(diagram), None)
4669 }
4670 Mode::Dot => {
4671 let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
4674 let mut svg_done = false;
4675 if let (true, Some(path)) = (graphviz, export.svg_path) {
4676 svg_done = sources::graphviz_svg(&content, path);
4677 if svg_done {
4678 export.svg_path = None;
4679 }
4680 }
4681 match sources::dot(&content, cli.resource.as_deref()) {
4682 Ok(diagram) => (diagram, None),
4683 Err((_, err)) if svg_done => {
4686 eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
4687 return success(&cli);
4688 }
4689 Err((class, err)) => return fail(&cli, class, err),
4690 }
4691 }
4692 Mode::Json => {
4693 let nested = cli.padding.is_some()
4710 || cli.panel.is_some()
4711 || cli.style.is_some()
4712 || cli.width.is_some()
4713 || cli.pager
4714 || cli.auto_pager;
4715 let json = json.expect("json parsed above").no_wrap(nested);
4716 #[cfg(feature = "json-escape-safe")]
4717 let json = json.escape_safe(true);
4718 let fit = measure_rendered(&console, &json);
4719 (Box::new(json), Some(fit))
4720 }
4721 Mode::Csv => {
4722 if let Err(err) = validate_labels(&cli) {
4723 return fail(&cli, ExitClass::Data, err);
4724 }
4725 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
4728 let table = match build_csv_table(
4729 &content,
4730 fallback,
4731 cli.title.as_deref(),
4732 cli.caption.as_deref(),
4733 (cli.source.head, cli.source.tail),
4734 cli.source.infer,
4735 ) {
4736 Some(table) => table,
4737 None => {
4740 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
4741 }
4742 };
4743 #[cfg(feature = "art")]
4746 let table = {
4747 let mut table = table;
4748 let label = |label: &Option<String>, style| {
4749 label.as_deref().and_then(|label| {
4750 micro::table_label(&console, label, style, cli.micro_project)
4751 })
4752 };
4753 if let Some(title) = label(&cli.title, "table.title") {
4754 table = table.title_text(title);
4755 }
4756 if let Some(caption) = label(&cli.caption, "table.caption") {
4757 table = table.caption_text(caption);
4758 }
4759 table
4760 };
4761 if cli.panel.is_none()
4765 && cli.padding.is_none()
4766 && cli.style.is_none()
4767 && cli.justify.is_none()
4768 && cli.max_width.is_none()
4769 && !cli.soft
4770 && !cli.pager
4771 && !cli.auto_pager
4772 && cli.export_html.is_none()
4773 && cli.export_svg.is_none()
4774 && !watch::capturing()
4775 && !micro_pending()
4776 {
4777 let mut options = console.options();
4778 options.max_width = cli.width.unwrap_or_else(|| {
4779 table
4780 .measure(&console, &options)
4781 .maximum
4782 .min(options.max_width)
4783 .max(1)
4784 });
4785 let stdout = std::io::stdout();
4786 let mut output = std::io::BufWriter::new(stdout.lock());
4787 let result = table
4788 .try_for_each_line(&console, &options, |line| {
4789 let line = Segment::crop_lines(&line, console.width());
4790 writeln!(output, "{}", console.segments_to_string(&line))
4791 })
4792 .and_then(|()| output.flush());
4793 if let Err(error) = result {
4794 if error.kind() == std::io::ErrorKind::BrokenPipe {
4797 return success(&cli);
4798 }
4799 return fail(
4800 &cli,
4801 ExitClass::Input,
4802 format!("could not write CSV output: {error}"),
4803 );
4804 }
4805 return success(&cli);
4806 }
4807 let fit = measure_rendered(&console, &table);
4808 (Box::new(table), Some(fit))
4809 }
4810 Mode::Syntax if cli.data.transform_option().is_some() => {
4813 let language = cli.source.lexer.as_deref().unwrap_or(&language);
4814 let code = match cli.source.line_range(&content) {
4817 Ok(Some((start, end))) => {
4818 let start = start.max(1) as usize - 1;
4819 let end = end.max(0) as usize;
4820 let lines: Vec<&str> = content.lines().collect();
4821 lines
4822 .get(start.min(lines.len())..end.min(lines.len()))
4823 .unwrap_or_default()
4824 .join("\n")
4825 }
4826 Ok(None) => content.clone(),
4827 Err(err) => return fail(&cli, ExitClass::Usage, err),
4828 };
4829 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
4830 let text = match cli
4831 .data
4832 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4833 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4834 {
4835 Ok(text) => text,
4836 Err(err) => return fail(&cli, ExitClass::Data, err),
4837 };
4838 let fit = text.measurement().1;
4839 let background = match text.base_style() {
4842 rich::style::StyleType::Style(style) => style.clone(),
4843 rich::style::StyleType::Name(_) => rich::Style::new(),
4844 };
4845 let padded =
4848 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
4849 (Box::new(padded), Some(fit))
4850 }
4851 Mode::Syntax => {
4852 let syntax = match cli.source.syntax(&content, &language) {
4853 Ok(syntax) => syntax,
4854 Err(err) => return fail(&cli, ExitClass::Usage, err),
4855 };
4856 let fit = syntax.measure(&console, &console.options()).maximum;
4859 (Box::new(syntax), Some(fit))
4860 }
4861 Mode::View => {
4862 let view: Box<dyn Renderable> = match view_as {
4863 Some(viewers::ViewAs::Patch) => {
4864 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
4865 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
4866 Ok(outcome) => outcome.renderable,
4867 Err(err) => return fail(&cli, ExitClass::Data, err),
4868 }
4869 }
4870 Some(viewers::ViewAs::Binary) => {
4871 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
4872 Ok(view) => view,
4873 Err(err) => return fail(&cli, ExitClass::Usage, err),
4874 }
4875 }
4876 Some(viewers::ViewAs::Source(ref lexer)) => {
4877 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
4878 eprintln!("{summary}");
4879 }
4880 viewers::source(&cli.viewers, &content, lexer)
4881 }
4882 _ => viewers::source(&cli.viewers, &content, "txt"),
4883 };
4884 let fit = view.measure(&console, &console.options()).maximum;
4885 (view, Some(fit))
4886 }
4887 Mode::AnsiExplain => {
4888 let view = tools::ansi_explain(&cli.tools, &content);
4889 let fit = view.measure(&console, &console.options()).maximum;
4890 (view, Some(fit))
4891 }
4892 Mode::Inspect => {
4893 let encoding = cli.extensions.encoding;
4894 let read = |path: &str| {
4895 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
4896 .map_err(|err| format!("cannot read {path}: {err}"))
4897 };
4898 let view = match inspect::build(&cli.data, &content, cli.resource.as_deref(), read) {
4899 Ok(view) => view,
4900 Err(err) => return fail(&cli, ExitClass::Data, err),
4901 };
4902 let fit = view.measure(&console, &console.options()).maximum;
4903 (view, Some(fit))
4904 }
4905 _ => {
4908 #[cfg(feature = "art")]
4911 let micro = (mode == Mode::Print && cli.emoji)
4912 .then(|| micro::print_markup(&console, &content, cli.micro_project))
4913 .flatten();
4914 #[cfg(feature = "art")]
4915 let mut text = match micro {
4916 Some(text) => text,
4917 None if mode == Mode::Print => console.build_text(&content),
4918 None => Text::new(content.as_str()),
4919 };
4920 #[cfg(not(feature = "art"))]
4921 let mut text = if mode == Mode::Print {
4922 console.build_text(&content)
4923 } else {
4924 Text::new(content.as_str())
4925 };
4926 if let Some(justify) = cli.text_justify {
4930 text.set_justify(justify);
4931 }
4932 if cli.source.no_wrap {
4933 text.set_no_wrap(Some(true));
4934 }
4935 let text = match cli
4936 .data
4937 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4938 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4939 {
4940 Ok(text) => text,
4941 Err(err) => return fail(&cli, ExitClass::Data, err),
4942 };
4943 let fit = text.measurement().1;
4944 (Box::new(text), Some(fit))
4945 }
4946 };
4947 decorate_and_emit(&cli, &console, &export, renderable, fit)
4948}
4949
4950#[allow(dead_code)]
4953fn exit_code(ok: bool) -> ExitCode {
4954 if ok {
4955 ExitCode::SUCCESS
4956 } else {
4957 ExitCode::FAILURE
4958 }
4959}
4960
4961fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
4962 match result {
4963 Ok(()) => success(cli),
4964 Err(message) => fail(cli, ExitClass::Input, message),
4965 }
4966}
4967
4968fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
4969 let output = console.render_export(renderable);
4970 let stdout = std::io::stdout();
4971 let mut lock = stdout.lock();
4972 lock.write_all(output.as_bytes())
4973}
4974
4975fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
4976 if cli.extensions.encoding.is_some() {
4977 return fail(
4978 cli,
4979 ExitClass::Usage,
4980 "--encoding is not supported with streaming JSONL/log mode",
4981 );
4982 }
4983 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
4984 return fail(
4985 cli,
4986 ExitClass::Usage,
4987 "streaming JSONL/log mode reads files or stdin, not URLs",
4988 );
4989 }
4990
4991 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
4992 Some(path) if path != "-" => {
4993 let file = &fs_path(path);
4994 if file.is_dir() {
4995 return fail(
4996 cli,
4997 ExitClass::Input,
4998 format!("cannot read {path}: is a directory, not a file"),
4999 );
5000 }
5001 match std::fs::File::open(file) {
5002 Ok(file) => Box::new(std::io::BufReader::new(file)),
5003 Err(err) => {
5004 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5005 }
5006 }
5007 }
5008 _ => {
5009 if std::io::stdin().is_terminal() {
5010 let eof = if cfg!(windows) {
5011 "Ctrl-Z then Enter"
5012 } else {
5013 "Ctrl-D"
5014 };
5015 eprintln!("rich: reading stdin; finish input with {eof}");
5016 }
5017 Box::new(std::io::BufReader::new(std::io::stdin()))
5018 }
5019 };
5020
5021 let mut line = String::new();
5022 let mut line_number = 0usize;
5023 loop {
5024 line.clear();
5025 let read = match input.read_line(&mut line) {
5026 Ok(read) => read,
5027 Err(err) => {
5028 return fail(
5029 cli,
5030 ExitClass::Input,
5031 format!("cannot read JSONL stream: {err}"),
5032 );
5033 }
5034 };
5035 if read == 0 {
5036 break;
5037 }
5038 line_number += 1;
5039 let record = line.trim_end_matches(['\r', '\n']);
5040 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5041 Ok(value) => value,
5042 Err(err) => {
5043 return fail(
5044 cli,
5045 ExitClass::Data,
5046 format!("invalid JSONL at line {line_number}: {err}"),
5047 );
5048 }
5049 };
5050 if cli.sanitize {
5051 sanitize_json_value(&mut value);
5052 }
5053 let wrote = if log_mode {
5054 if cli.log_presentation == "rich" {
5055 write_rendered_stdout(console, &structured_log::event(&value))
5056 } else {
5057 let text = Text::new(format_log_record(&value));
5058 write_rendered_stdout(console, &text)
5059 }
5060 } else {
5061 let source = serde_json::to_string(&value).expect("JSON value serializes");
5062 let json = Json::new(&source).expect("serialized JSON parses");
5063 write_rendered_stdout(console, &json)
5064 };
5065 if let Err(error) = wrote {
5066 if error.kind() == std::io::ErrorKind::BrokenPipe {
5067 return success(cli);
5068 }
5069 return fail(
5070 cli,
5071 ExitClass::Input,
5072 format!("could not write JSONL output: {error}"),
5073 );
5074 }
5075 }
5076 success(cli)
5077}
5078
5079fn format_log_record(value: &serde_json::Value) -> String {
5080 let Some(object) = value.as_object() else {
5081 return serde_json::to_string(value).expect("JSON value serializes");
5082 };
5083 let take_value = |keys: &[&'static str]| {
5084 keys.iter().find_map(|key| {
5085 object.get(*key).map(|value| {
5086 let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
5087 serde_json::to_string(value).expect("JSON value serializes")
5088 });
5089 (*key, display, value.is_string())
5090 })
5091 })
5092 };
5093 let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
5094 let level = take_value(&["level", "severity"]);
5095 let message = take_value(&["message", "msg"]);
5096 let mut rest = serde_json::Map::new();
5097 for (key, value) in object {
5098 let consumed = timestamp
5099 .as_ref()
5100 .is_some_and(|(consumed, _, _)| consumed == key)
5101 || level
5102 .as_ref()
5103 .is_some_and(|(consumed, _, _)| consumed == key)
5104 || message
5105 .as_ref()
5106 .is_some_and(|(consumed, _, _)| consumed == key);
5107 if !consumed {
5108 rest.insert(key.clone(), value.clone());
5109 }
5110 }
5111 let mut parts = Vec::new();
5112 if let Some((_, timestamp, _)) = timestamp {
5113 parts.push(timestamp);
5114 }
5115 if let Some((_, level, is_string)) = level {
5116 parts.push(if is_string {
5117 level.to_ascii_uppercase()
5118 } else {
5119 level
5120 });
5121 }
5122 if let Some((_, message, _)) = message {
5123 parts.push(message);
5124 }
5125 if !rest.is_empty() {
5126 parts.push(
5127 serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
5128 );
5129 }
5130 if parts.is_empty() {
5131 serde_json::to_string(value).expect("JSON value serializes")
5132 } else {
5133 parts.join(" ")
5134 }
5135}
5136
5137fn is_number(value: &str) -> bool {
5143 let body = value.strip_prefix('-').unwrap_or(value);
5144 let (integer, fraction) = match body.split_once('.') {
5145 Some((integer, fraction)) => (integer, Some(fraction)),
5146 None => (body, None),
5147 };
5148 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5149 digits(integer) && fraction.is_none_or(digits)
5150}
5151
5152fn build_csv_table(
5171 content: &str,
5172 fallback_delimiter: Option<char>,
5173 title: Option<&str>,
5174 caption: Option<&str>,
5175 window: (Option<usize>, Option<usize>),
5176 infer: bool,
5177) -> Option<Table> {
5178 let sample: String = content.chars().take(1024).collect();
5181 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5182 let (dialect, header) = match sniffed {
5183 Some(sniffed) => sniffed,
5184 None => (Dialect::excel(fallback_delimiter?), true),
5185 };
5186 let mut rows = read_rows(content, &dialect);
5187 if window.0.is_some() || window.1.is_some() {
5190 let header_row = if header && !rows.is_empty() {
5191 Some(rows.remove(0))
5192 } else {
5193 None
5194 };
5195 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5196 match window {
5197 (Some(head), _) => data.truncate(head),
5198 (None, Some(tail)) => {
5199 data.drain(..data.len().saturating_sub(tail));
5200 }
5201 (None, None) => {}
5202 }
5203 rows = header_row.into_iter().chain(data).collect();
5204 }
5205 let types = infer.then(|| infer_csv_types(&rows, header));
5206 Some(render_csv(rows, header, title, caption, types))
5207}
5208
5209fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5213 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5214 let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5215 for row in rows
5216 .iter()
5217 .skip(usize::from(has_header))
5218 .filter(|row| !row.is_empty())
5219 {
5220 data.push(
5221 (0..width)
5222 .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5223 );
5224 }
5225 Inferrer::new()
5226 .infer(&data)
5227 .columns()
5228 .iter()
5229 .map(|column| column.data_type())
5230 .collect()
5231}
5232
5233fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5240 let name = resource.unwrap_or_default().to_lowercase();
5241 if name.ends_with(".csv") {
5242 Some(',')
5243 } else if name.ends_with(".tsv") {
5244 Some('\t')
5245 } else {
5246 None
5247 }
5248}
5249
5250fn render_csv(
5260 rows: Vec<Vec<String>>,
5261 has_header: bool,
5262 title: Option<&str>,
5263 caption: Option<&str>,
5264 types: Option<Vec<InferredType>>,
5265) -> Table {
5266 let mut table = Table::new()
5267 .border_style(Style::parse("blue").expect("valid style"))
5268 .show_header(has_header || types.is_some())
5269 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5270 if let Some(title) = title {
5271 table = table.title(title);
5272 }
5273 if let Some(caption) = caption {
5274 table = table.caption(caption);
5275 }
5276
5277 let empty: Vec<String> = Vec::new();
5278 let (header, body) = if has_header {
5279 match rows.split_first() {
5280 Some((header, body)) => (header, body),
5281 None => return table,
5282 }
5283 } else {
5284 (&empty, rows.as_slice())
5285 };
5286 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5288
5289 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5294 let columns = header.len().max(widest);
5295
5296 for index in 0..columns {
5297 let inferred = types
5302 .as_ref()
5303 .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5304 let numeric = match inferred {
5305 Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5306 None => data.iter().all(|row| match row.get(index) {
5307 Some(value) => value.is_empty() || is_number(value),
5308 None => false,
5309 }),
5310 };
5311 let name = header.get(index).map(String::as_str).unwrap_or("");
5312 if let Some(inferred) = inferred {
5313 let mut heading = Text::new(name);
5314 if !name.is_empty() {
5315 heading.append("\n", None);
5316 }
5317 heading.append(
5318 inferred.name(),
5319 Some(Style::parse("dim").expect("valid style").into()),
5320 );
5321 let justify = if numeric {
5322 Justify::Right
5323 } else {
5324 Justify::Left
5325 };
5326 table.add_column_text(heading, justify);
5327 } else if numeric {
5328 table.add_column_justify(name, Justify::Right);
5329 } else {
5330 table.add_column(name);
5331 }
5332 if numeric {
5333 table.column_style(Style::parse("bold green").expect("valid style"));
5334 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5335 }
5336 }
5337 drop(data);
5340 table.extend_owned_rows(
5341 rows.into_iter()
5342 .skip(usize::from(has_header))
5343 .filter(|row| !row.is_empty())
5344 .collect(),
5345 );
5346 table
5347}
5348
5349fn join_source(value: &serde_json::Value) -> String {
5352 match value {
5353 serde_json::Value::String(s) => s.clone(),
5354 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5355 _ => String::new(),
5356 }
5357}
5358
5359fn join_traceback(value: &serde_json::Value) -> String {
5363 match value {
5364 serde_json::Value::String(s) => s.clone(),
5365 serde_json::Value::Array(a) => a
5366 .iter()
5367 .filter_map(|v| v.as_str())
5368 .collect::<Vec<_>>()
5369 .join(
5370 "
5371",
5372 ),
5373 _ => String::new(),
5374 }
5375}
5376
5377fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5381 let base_style = Style::parse(base).ok();
5382 let number_style = Style::parse(number).ok();
5383 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5384 let mut text = Text::new("");
5385 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5386 text.append(&n, number_style.map(Into::into));
5387 text.append("]:", base_style.map(Into::into));
5388 text
5389}
5390
5391struct Notebook {
5394 items: Vec<Box<dyn Renderable>>,
5395 justify: Option<Justify>,
5398}
5399
5400impl Renderable for Notebook {
5401 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5402 let mut options = options.clone();
5403 if let Some(justify) = self.justify {
5404 options.justify = justify;
5405 }
5406 let mut segments = Vec::new();
5407 for item in &self.items {
5408 let rendered = item.rich_render(console, &options);
5409 if !rendered.is_empty() {
5410 segments.extend(rendered);
5411 segments.push(Segment::line());
5412 }
5413 }
5414 if !segments.is_empty() {
5416 segments.pop();
5417 }
5418 segments
5419 }
5420
5421 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5422 self.items
5423 .iter()
5424 .map(|item| item.measure(console, options))
5425 .fold(Measurement::new(0, 0), |width, item| {
5426 Measurement::new(
5427 width.minimum.max(item.minimum),
5428 width.maximum.max(item.maximum),
5429 )
5430 })
5431 }
5432}
5433
5434fn notebook_ansi(source: &str) -> Text {
5436 let mut result = Text::new("");
5437 for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5438 if index > 0 {
5439 result.append("\n", None);
5440 }
5441 result = result.append_text(line);
5442 }
5443 result
5444}
5445
5446fn build_ipynb(
5449 notebook: &serde_json::Value,
5450 hyperlinks: bool,
5451 justify: Option<Justify>,
5452 source_options: &SourceOptions,
5453) -> Result<Notebook, String> {
5454 let language = notebook["metadata"]["kernelspec"]["language"]
5455 .as_str()
5456 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5457 .unwrap_or("python");
5458 let empty = Vec::new();
5459 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5460 let mut new_line = true;
5461 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5462 if new_line {
5463 items.push(text(""));
5464 }
5465 if cell.get("execution_count").is_some() {
5466 items.push(Box::new(io_label(
5467 "In ",
5468 cell["execution_count"].as_i64().filter(|n| *n != 0),
5469 "green",
5470 "#66ff00",
5471 )));
5472 }
5473 let source = join_source(&cell["source"]);
5474 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5475 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5476 "code" => Box::new(
5479 Panel::new(Box::new(source_options.syntax(&source, language)?))
5480 .border_style(Style::parse("dim").expect("valid style")),
5481 ),
5482 _ => text(&source),
5483 });
5484 new_line = true;
5485 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5486 let rendered = match output["output_type"].as_str().unwrap_or("") {
5487 "stream" => {
5488 new_line = false;
5489 notebook_ansi(&join_source(&output["text"]))
5490 }
5491 "error" => {
5492 new_line = true;
5493 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5494 }
5495 "execute_result" => {
5496 new_line = true;
5497 let mut label = io_label(
5498 "Out",
5499 output["execution_count"].as_i64().filter(|n| *n != 0),
5500 "red",
5501 "#ee4b2b",
5502 );
5503 label.append("\n", None);
5504 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5505 }
5506 _ => continue,
5507 };
5508 items.push(Box::new(rendered));
5509 }
5510 }
5511 Ok(Notebook { items, justify })
5512}
5513
5514fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5515 let displayed = |value: f32| {
5518 format!("{value:.1}")
5519 .parse::<f32>()
5520 .expect("formatted percentage is a number")
5521 };
5522 displayed(changed) > displayed(limit)
5523}
5524
5525fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5528 let mut contents = Vec::with_capacity(cli.resources.len());
5529 for resource in &cli.resources {
5530 let content = if is_url(resource) {
5531 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5532 } else {
5533 read_resource(Some(resource), cli.extensions.encoding)
5534 .map_err(|err| format!("cannot read {resource}: {err}"))
5535 };
5536 match content {
5537 Ok(content) => contents.push(content),
5538 Err(err) => return fail(cli, ExitClass::Input, err),
5539 }
5540 }
5541 let names: Vec<String> = cli
5542 .resources
5543 .iter()
5544 .map(|r| {
5545 if r == "-" {
5546 "<stdin>".to_string()
5547 } else {
5548 r.clone()
5549 }
5550 })
5551 .collect();
5552 let outcome = match tools::text_diff(
5553 &cli.tools,
5554 &names,
5555 &contents,
5556 cli.diff_threshold,
5557 cli.sanitize,
5558 ) {
5559 Ok(outcome) => outcome,
5560 Err(err) => return fail(cli, ExitClass::Data, err),
5561 };
5562 let fit = outcome
5563 .renderable
5564 .measure(console, &console.options())
5565 .maximum;
5566 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5567 if code != ExitCode::SUCCESS || !outcome.failed {
5568 return code;
5569 }
5570 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5571}
5572
5573#[cfg(feature = "art")]
5589fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5590 if fs_path(path).is_dir() {
5591 return Err(format!("cannot read {path}: is a directory, not a file"));
5592 }
5593 match rich_art::image::open(fs_path(path)) {
5594 Ok(image) => Ok(image),
5595 Err(err) => {
5596 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5597 let hint = match &err {
5598 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5599 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5600 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5601 _ => None,
5602 },
5603 _ => None,
5604 };
5605 Err(format!(
5606 "cannot read {path}: {}",
5607 hint.unwrap_or_else(|| err.to_string())
5608 ))
5609 }
5610 }
5611}
5612
5613#[cfg(feature = "art")]
5619fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5620 match mode {
5621 ImageMode::Auto => rich_art::ImageMode::Auto,
5622 ImageMode::Sixel => rich_art::ImageMode::Sixel,
5623 ImageMode::Blocks => rich_art::ImageMode::Blocks,
5624 ImageMode::Braille => rich_art::ImageMode::Braille,
5625 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5626 ImageMode::Ascii => rich_art::ImageMode::Ascii,
5627 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
5628 }
5629}
5630
5631#[cfg(feature = "art")]
5638fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5639 use rich_art::ImageArt;
5640
5641 let Some(resource) = cli.resource.as_deref() else {
5642 return fail(
5643 cli,
5644 ExitClass::Usage,
5645 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
5646 );
5647 };
5648
5649 let image = if resource == "-" {
5650 let mut buffer = Vec::new();
5651 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
5652 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
5653 }
5654 match rich_art::image::load_from_memory(&buffer) {
5655 Ok(image) => image,
5656 Err(err) => {
5657 return fail(
5658 cli,
5659 ExitClass::Input,
5660 format!("cannot decode image from stdin: {err}"),
5661 )
5662 }
5663 }
5664 } else {
5665 match open_image_path(resource) {
5666 Ok(image) => image,
5667 Err(message) => return fail(cli, ExitClass::Input, message),
5668 }
5669 };
5670
5671 let width = cli.width.unwrap_or_else(|| console.width());
5672 let mut art = ImageArt::new(image)
5673 .transforms(rich_art::ImageTransforms {
5674 rotation: match cli.image_rotate {
5675 Some(90) => rich_art::Rotation::Clockwise90,
5676 Some(180) => rich_art::Rotation::Clockwise180,
5677 Some(270) => rich_art::Rotation::Clockwise270,
5678 _ => rich_art::Rotation::None,
5679 },
5680 flip_horizontal: cli.image_flip_horizontal,
5681 flip_vertical: cli.image_flip_vertical,
5682 grayscale: cli.image_grayscale,
5683 brightness: cli.image_brightness.unwrap_or(1.0),
5684 contrast: cli.image_contrast.unwrap_or(1.0),
5685 gamma: cli.image_gamma.unwrap_or(1.0),
5686 })
5687 .mode(to_art_image_mode(cli.image_mode))
5688 .width(width)
5689 .color(!cli.no_color);
5690 if let Some(height) = cli.height {
5691 art = art.height(height);
5692 }
5693 if let Some(columns) = cli.image_max_width {
5694 art = art.max_width(columns);
5695 }
5696 if let Some(rows) = cli.image_max_height {
5697 art = art.max_height(rows);
5698 }
5699
5700 if let Some(fit) = cli.image_fit.as_deref() {
5701 art = art.fit(match fit {
5702 "cover" => rich_art::ImageFit::Cover,
5703 "stretch" => rich_art::ImageFit::Stretch,
5704 "native" => rich_art::ImageFit::Native,
5705 _ => rich_art::ImageFit::Contain,
5706 });
5707 }
5708 if let Some(anchor) = cli.image_anchor.as_deref() {
5709 use rich_art::ImageAnchor;
5710 art = art.anchor(match anchor {
5711 "top" => ImageAnchor::Top,
5712 "bottom" => ImageAnchor::Bottom,
5713 "left" => ImageAnchor::Left,
5714 "right" => ImageAnchor::Right,
5715 "top-left" => ImageAnchor::TopLeft,
5716 "top-right" => ImageAnchor::TopRight,
5717 "bottom-left" => ImageAnchor::BottomLeft,
5718 "bottom-right" => ImageAnchor::BottomRight,
5719 _ => ImageAnchor::Center,
5720 });
5721 }
5722 match cli.image_background {
5723 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
5724 Some(ImageBackdrop::Terminal) => {
5725 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
5726 }
5727 Some(ImageBackdrop::Checkerboard) => {
5728 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
5729 }
5730 None => {}
5731 }
5732 let (color_mode, dither, distance) = image_color_processing(cli);
5733 art = art
5734 .color_mode(color_mode)
5735 .dither(dither)
5736 .color_distance(distance);
5737
5738 let options = console.options();
5743 if let Err(err) = art.render(console, &options) {
5744 let message = match err {
5748 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
5749 "this terminal (TERM={}) is not known to support Sixel graphics; set \
5750 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
5751 --image-mode blocks, braille or ascii",
5752 std::env::var("TERM").unwrap_or_default()
5753 ),
5754 err => err.to_string(),
5755 };
5756 return fail(cli, ExitClass::Input, message);
5757 }
5758
5759 let terminal_only = Export {
5760 html_path: None,
5761 svg_path: None,
5762 ..*export
5763 };
5764 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
5765 return fail(cli, ExitClass::Input, message);
5766 }
5767 if export.html_path.is_some() || export.svg_path.is_some() {
5768 use rich::protocol::RenderEnvironment;
5769 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
5770 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
5771 caps.color_system = if cli.no_color {
5772 None
5773 } else {
5774 Some(ColorSystem::Truecolor)
5775 };
5776 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
5777 let export_console = target.console();
5778 if let Err(err) =
5779 art.render_with_environment(&export_console, &export_console.options(), &target)
5780 {
5781 return fail(cli, ExitClass::Input, err.to_string());
5782 }
5783 let segments = target.segments(&art);
5784 debug_assert!(!target.capabilities().interactive);
5785 if let Err(message) = save_exports(&export_console, export, &segments) {
5786 return fail(cli, ExitClass::Input, message);
5787 }
5788 }
5789 success(cli)
5790}
5791
5792#[cfg(feature = "art")]
5793fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5794 use rich::Table;
5795 use rich_art::imagediff::{diff, DiffSettings};
5796 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
5797
5798 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
5799 let before = match open_image_path(before_path) {
5800 Ok(image) => image,
5801 Err(message) => return fail(cli, ExitClass::Input, message),
5802 };
5803 let after = match open_image_path(after_path) {
5804 Ok(image) => image,
5805 Err(message) => return fail(cli, ExitClass::Input, message),
5806 };
5807
5808 let report = match diff(&before, &after, &DiffSettings::default()) {
5809 Ok(report) => report,
5810 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
5811 };
5812
5813 let width = cli.width.unwrap_or_else(|| console.width());
5814 let changed = report.changed_fraction * 100.0;
5815 let naive = report.naive_changed_fraction * 100.0;
5816
5817 let failed = cli
5826 .diff_threshold
5827 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
5828
5829 let render_report = |c: &Console, for_export: bool| {
5830 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
5833
5834 let has_color = c.color_system().is_some() && !cli.no_color;
5841 let is_terminal = c.is_terminal();
5842 let mut mode = cli.image_mode;
5843 if mode == ImageMode::Auto {
5844 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
5845 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
5846 }
5847 mode = if !has_color {
5848 ImageMode::Ascii
5849 } else if is_terminal && rich_art::sixel::is_probably_supported() {
5850 ImageMode::Sixel
5851 } else {
5852 ImageMode::Blocks
5853 };
5854 }
5855
5856 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
5860 if !for_export && cli.report_format == ReportFormat::Human {
5861 eprintln!("rich: no colour available, drawing the diff as ASCII art");
5862 }
5863 mode = ImageMode::Ascii;
5864 }
5865 if mode == ImageMode::Sixel && !is_terminal {
5866 if cli.image_mode != ImageMode::Sixel
5867 && !for_export
5868 && cli.report_format == ReportFormat::Human
5869 {
5870 eprintln!(
5871 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
5872 if has_color { "blocks" } else { "ASCII art" }
5873 );
5874 }
5875 mode = if has_color {
5876 ImageMode::Blocks
5877 } else {
5878 ImageMode::Ascii
5879 };
5880 }
5881
5882 match mode {
5883 ImageMode::None => {}
5884 ImageMode::Ascii => c.print(
5885 &AsciiArt::new(report.heatmap())
5886 .width(width)
5887 .color(has_color),
5888 ),
5889 ImageMode::Blocks => c.print(
5890 &BlockArt::new(report.heatmap())
5891 .width(width)
5892 .height(rows_cap),
5893 ),
5894 ImageMode::Quadrants => c.print(
5895 &rich_art::QuadrantArt::new(report.heatmap())
5896 .width(width)
5897 .height(rows_cap),
5898 ),
5899 ImageMode::Braille => c.print(
5900 &BrailleArt::new(report.heatmap())
5901 .width(width)
5902 .height(rows_cap),
5903 ),
5904 ImageMode::Sixel => {
5905 let art = SixelArt::new(report.heatmap())
5906 .width(width)
5907 .height(rows_cap);
5908 if art.encode(width).is_some() {
5911 c.print(&art);
5912 } else {
5913 if cli.report_format == ReportFormat::Human {
5914 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
5915 }
5916 c.print(
5917 &BlockArt::new(report.heatmap())
5918 .width(width)
5919 .height(rows_cap),
5920 );
5921 }
5922 }
5923 ImageMode::Auto => unreachable!("resolved above"),
5924 }
5925 if mode != ImageMode::None {
5929 c.print_str("");
5930 }
5931 c.print_str(&format!(
5932 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
5933 [dim](a plain pixel diff would say {naive:.1}%)[/]"
5934 ));
5935
5936 if report.regions.is_empty() {
5937 c.print_str("[dim]No region large enough to report.[/]");
5938 } else {
5939 let mut table = Table::new().title("Where it changed");
5940 table.add_column("#");
5941 table.add_column_justify("Share", rich::Justify::Right);
5942 table.add_column_justify("Mean ΔE", rich::Justify::Right);
5943 table.add_column_justify("Area px", rich::Justify::Right);
5944 table.add_column("Box (x,y w×h)");
5945 for (rank, r) in report.regions.iter().enumerate() {
5946 table.add_row(&[
5947 &format!("{}", rank + 1),
5948 &format!("{:.0}%", r.share_of_change * 100.0),
5949 &format!("{:.1}", r.mean_delta_e),
5950 &format!("{}", r.area_px),
5951 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
5952 ]);
5953 }
5954 c.print(&table);
5955 }
5956
5957 if let Some(limit) = cli.diff_threshold {
5961 if failed {
5962 c.print_str(&format!(
5963 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
5964 ));
5965 } else {
5966 c.print_str(&format!(
5967 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
5968 ));
5969 }
5970 }
5971 };
5972
5973 let terminal_only = Export {
5974 html_path: None,
5975 svg_path: None,
5976 ..*export
5977 };
5978 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
5979 if export.html_path.is_some() || export.svg_path.is_some() {
5980 let mut export_console = Console::builder()
5984 .force_terminal(false)
5985 .color_system(Some(ColorSystem::Truecolor))
5986 .no_color(cli.no_color)
5987 .width(console.width())
5988 .height(console.height())
5989 .theme(console.theme().clone())
5990 .build();
5991 export_console.install_extensions();
5992 let segments = export_console.record_output(|c| render_report(c, true));
5993 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
5994 }
5995
5996 if let Err(message) = wrote {
5997 return fail(cli, ExitClass::Input, message);
5998 }
5999 if failed {
6000 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6001 }
6002 success(cli)
6003}
6004
6005#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6008#[cfg_attr(not(feature = "art"), allow(dead_code))]
6009enum ImageBackdrop {
6010 Color([u8; 3]),
6012 Terminal,
6014 Checkerboard,
6016}
6017
6018impl ImageBackdrop {
6019 fn parse(value: &str) -> Option<Self> {
6020 match value {
6021 "default" => return Some(ImageBackdrop::Terminal),
6022 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6023 _ => {}
6024 }
6025 let hex = value.strip_prefix('#')?;
6026 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6027 return None;
6028 }
6029 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6030 Some(ImageBackdrop::Color([
6031 channel(0)?,
6032 channel(1)?,
6033 channel(2)?,
6034 ]))
6035 }
6036}
6037
6038#[cfg(feature = "art")]
6041fn image_color_processing(
6042 cli: &Cli,
6043) -> (
6044 rich_art::ImageColorMode,
6045 rich_art::Dither,
6046 rich_art::ColorDistance,
6047) {
6048 use rich_art::{ColorDistance, Dither, ImageColorMode};
6049 let mode = match cli.image_color.as_deref() {
6050 Some("ansi256") => ImageColorMode::Ansi256,
6051 Some("ansi16") => ImageColorMode::Ansi16,
6052 Some("grayscale") => ImageColorMode::Grayscale,
6053 _ => ImageColorMode::TrueColor,
6054 };
6055 let dither = match cli.image_dither.as_deref() {
6056 Some("floyd-steinberg") => Dither::FloydSteinberg,
6057 Some("bayer4x4") => Dither::Bayer4x4,
6058 Some("atkinson") => Dither::Atkinson,
6059 _ => Dither::None,
6060 };
6061 let distance = match cli.image_color_distance.as_deref() {
6062 Some("oklab") => ColorDistance::Oklab,
6063 _ => ColorDistance::Rgb,
6064 };
6065 (mode, dither, distance)
6066}
6067
6068#[cfg(feature = "art")]
6069fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6070 use rich_art::{AnimatedArt, Repeat, Stage};
6071
6072 if cli.resources.is_empty() {
6073 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6074 }
6075 let count = cli.resources.len();
6076 const GAP: usize = 2;
6077
6078 let total = cli.width.unwrap_or_else(|| console.width());
6080 let per_gif = total
6081 .saturating_sub(GAP * count.saturating_sub(1))
6082 .checked_div(count)
6083 .unwrap_or(total)
6084 .max(8);
6085
6086 let repeat = match cli.loops {
6087 Some(0) => Repeat::Forever,
6088 Some(n) => Repeat::Times(n),
6089 None => Repeat::Times(1),
6090 };
6091
6092 let mut stage = Stage::new().gap(GAP);
6093 for path in &cli.resources {
6094 match AnimatedArt::from_path(path) {
6095 Ok(art) => {
6096 let (color_mode, dither, distance) = image_color_processing(cli);
6097 stage = stage.with(
6098 art.width(per_gif)
6099 .blocks(cli.extensions.gif_blocks())
6100 .color(!cli.no_color)
6101 .color_mode(color_mode)
6102 .dither(dither)
6103 .color_distance(distance)
6104 .repeat(repeat)
6105 .max_fps(30.0),
6107 );
6108 }
6109 Err(err) => {
6110 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6111 }
6112 }
6113 }
6114
6115 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6116 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6117 }
6118
6119 let mut builder = Console::builder().no_color(cli.no_color);
6121 if cli.themed() {
6122 builder = builder.theme(cli_theme(cli));
6123 }
6124 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6127 builder = builder.force_terminal(terminal == "1");
6128 }
6129 if let Some(width) = cli.width {
6130 builder = builder.width(width);
6131 }
6132 match stage.play_stdout(builder.build()) {
6133 Ok(()) => success(cli),
6134 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6135 }
6136}
6137
6138#[cfg(not(feature = "art"))]
6140fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6141 fail(
6142 _cli,
6143 ExitClass::Usage,
6144 "this build has no GIF support (rebuild with the `art` feature)",
6145 )
6146}
6147
6148struct Export<'a> {
6151 html_path: Option<&'a str>,
6153 svg_path: Option<&'a str>,
6155 svg_title: &'a str,
6157 pager: bool,
6159 auto_pager: bool,
6160}
6161
6162impl Export<'_> {
6163 fn terminal_only(&self) -> bool {
6166 !watch::capturing()
6167 && self.html_path.is_none()
6168 && self.svg_path.is_none()
6169 && !self.pager
6170 && !self.auto_pager
6171 }
6172}
6173
6174fn should_page(
6185 explicit: bool,
6186 automatic: bool,
6187 terminal: bool,
6188 lines: usize,
6189 height: usize,
6190) -> bool {
6191 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6192}
6193
6194fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6195 if export.terminal_only() {
6196 render(console);
6197 return Ok(());
6198 }
6199 emit_segments(console, export, console.record_output(render))
6200}
6201
6202fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6205 if watch::capturing() {
6206 watch::capture_segments(segments);
6208 return Ok(());
6209 }
6210 let mut first_error = None;
6211
6212 let terminal = console.segments_to_string(&segments);
6214 if should_page(
6215 export.pager,
6216 export.auto_pager,
6217 std::io::stdout().is_terminal(),
6218 terminal.lines().count(),
6219 console.height(),
6220 ) {
6221 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6224 first_error = Some(format!("cannot page output: {err}"));
6225 }
6226 } else {
6227 let stdout = std::io::stdout();
6228 let mut lock = stdout.lock();
6229 if let Err(err) = lock.write_all(terminal.as_bytes()) {
6230 first_error = Some(format!("could not write output: {err}"));
6231 }
6232 }
6233
6234 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6235}
6236
6237fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6238 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6239 let segments = filtered.as_slice();
6240 let mut first_error = None;
6241 if let Some(path) = export.html_path {
6242 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6244 if let Err(err) = std::fs::write(fs_path(path), html) {
6245 first_error = Some(format!("failed to save HTML: {err}"));
6246 }
6247 }
6248 if let Some(path) = export.svg_path {
6249 let svg = rich::svg::export_svg(
6250 segments,
6251 &rich::SVG_EXPORT_THEME,
6252 export.svg_title,
6253 "rich-cli",
6256 console.width(),
6257 );
6258 if let Err(err) = std::fs::write(fs_path(path), svg) {
6259 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6260 }
6261 }
6262 first_error.map_or(Ok(()), Err)
6263}
6264
6265fn print_help(no_color: bool) {
6267 cli_spec::print_help(no_color);
6268}
6269
6270fn build_demo_console(no_color: bool) -> Console {
6271 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6276 let mut console = Console::builder()
6277 .force_terminal(to_terminal)
6278 .no_color(no_color)
6279 .color_system(if to_terminal {
6280 Some(ColorSystem::Truecolor)
6281 } else {
6282 None
6283 })
6284 .theme(rich_ext::extended_theme())
6287 .build();
6288 console.install_extensions();
6289 console
6290}
6291
6292fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6293 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6294 let console = build_demo_console(no_color);
6295
6296 demo::section(&console, delay, "rs-rich-cli");
6297 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6298 console.print_str(
6299 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6300 );
6301
6302 demo::section(&console, delay, "markup · color · theme · extension");
6304 console.print_str(
6305 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6306 );
6307 console.print_str(
6308 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6309 );
6310 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6311 let mut themed = build_demo_console(no_color);
6313 let release =
6314 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6315 themed.use_theme(release).print_str(
6316 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6317 );
6318 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6319 let hl = Console::builder()
6325 .force_terminal(to_terminal)
6326 .no_color(no_color)
6327 .color_system(if to_terminal {
6328 Some(ColorSystem::Truecolor)
6329 } else {
6330 None
6331 })
6332 .highlight(true)
6333 .build();
6334 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6335 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6337 ISO8601Highlighter::new().highlight(&mut stamp);
6338 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6339 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6340
6341 demo::section(&console, delay, "rule");
6343 console.print(&Rule::line());
6344 demo::section(&console, delay, "centered");
6345 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6346 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6347
6348 demo::section(&console, delay, "panel");
6350 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6351 console.print(
6352 &Panel::new(text("square box"))
6353 .box_set(SQUARE)
6354 .title("square"),
6355 );
6356 console.print(
6357 &Panel::new(text("double box"))
6358 .box_set(DOUBLE)
6359 .title("double"),
6360 );
6361 console.print(
6362 &Panel::new(text("title + subtitle"))
6363 .title("top")
6364 .subtitle("bottom"),
6365 );
6366 let legacy = Console::builder()
6368 .force_terminal(to_terminal)
6369 .no_color(no_color)
6370 .color_system(if to_terminal {
6371 Some(ColorSystem::Truecolor)
6372 } else {
6373 None
6374 })
6375 .width(console.width())
6376 .legacy_windows(true)
6377 .build();
6378 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6379
6380 demo::section(&console, delay, "padding");
6382 console.print(
6383 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6384 );
6385
6386 demo::section(&console, delay, "styled");
6388 console.print(&Styled::new(
6389 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6390 Style::parse("green").unwrap(),
6391 ));
6392
6393 demo::section(&console, delay, "align");
6395 console.print(&Align::left(text("← left")));
6396 console.print(&Align::center(text("center")));
6397 console.print(&Align::right(text("right →")));
6398
6399 demo::section(&console, delay, "justify");
6401 console.print_justified("left justified", Justify::Left);
6402 console.print_justified("centered", Justify::Center);
6403 console.print_justified("right justified", Justify::Right);
6404
6405 demo::section(&console, delay, "constrain (width 24)");
6407 console.print(&Constrain::new(
6408 Box::new(Panel::new(text("constrained")).title("≤24")),
6409 Some(24),
6410 ));
6411
6412 demo::section(&console, delay, "table");
6414 let mut table = Table::new().title("ported renderables");
6415 table
6417 .add_column("Renderable")
6418 .column_style(Style::parse("cyan").unwrap());
6419 table.add_column("Module").column_width(12);
6420 table.add_column_justify("Parity", Justify::Center);
6421 for (renderable, module, parity) in [
6422 ("Text / markup", "text, markup", "✓"),
6423 ("Rule", "rule", "✓"),
6424 ("Panel", "panel", "✓"),
6425 ("Padding", "padding", "✓"),
6426 ("Align", "align", "✓"),
6427 ("Constrain", "constrain", "✓"),
6428 ("Columns", "columns", "✓"),
6429 ("Table", "table", "✓"),
6430 ("Tree", "tree", "✓"),
6431 ("ProgressBar", "progress_bar", "✓"),
6432 ] {
6433 table.add_row(&[renderable, module, parity]);
6434 }
6435 console.print(&table);
6436
6437 demo::section(&console, delay, "columns");
6439 let months = [
6440 "January",
6441 "February",
6442 "March",
6443 "April",
6444 "May",
6445 "June",
6446 "July",
6447 "August",
6448 "September",
6449 "October",
6450 "November",
6451 "December",
6452 ];
6453 console.print(&Columns::new(
6454 months.iter().map(|m| m.to_string()).collect(),
6455 ));
6456
6457 demo::section(&console, delay, "tree");
6459 let mut tree = Tree::new("rs-rich-cli");
6460 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6461 core.add("color · style · text · console");
6462 core.add("box · rule · panel · padding · align");
6463 core.add("table · tree · wrap · filesize");
6464 tree.add("crates/rich-ext (our extensions)");
6465 tree.add("crates/rich-cli (this binary)");
6466 console.print(&tree);
6467
6468 demo::section(&console, delay, "progress bar");
6470 for pct in [0.0, 33.0, 66.0, 100.0] {
6471 console.print(&ProgressBar::new(100.0, pct).width(48));
6472 }
6473
6474 demo::section(&console, delay, "progress");
6478 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6479 let set_time =
6480 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6481 let reader = clock.clone();
6482 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6483 let mut progress = Progress::new().clock(now.clone());
6484 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6485 let processing = progress.add_task("Processing", 100.0, 0.0);
6486 progress.add_task("Waiting", 100.0, 0.0);
6487 set_time(4.0);
6488 progress.advance(downloading, 20.0);
6489 progress.advance(processing, 60.0);
6490 set_time(8.0);
6491 progress.advance(downloading, 30.0);
6492 progress.advance(processing, 40.0);
6493 console.print(&progress);
6494
6495 let mut transfer = Progress::new().clock(now).columns(vec![
6497 ProgressColumn::spinner(),
6498 ProgressColumn::Description,
6499 ProgressColumn::Bar,
6500 ProgressColumn::Download,
6501 ProgressColumn::TransferSpeed,
6502 ProgressColumn::TimeElapsed,
6503 ProgressColumn::time_remaining(),
6504 ]);
6505 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6506 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6507 set_time(12.0);
6508 transfer.advance(iso, 1_200_000_000.0);
6509 transfer.advance(notes, 20_000.0);
6510 set_time(20.0);
6511 transfer.advance(iso, 900_000_000.0);
6512 transfer.advance(notes, 22_000.0);
6513 console.print(&transfer);
6514
6515 let mut mofn = Progress::new().columns(vec![
6517 ProgressColumn::Description,
6518 ProgressColumn::Bar,
6519 ProgressColumn::MofN,
6520 ]);
6521 mofn.add_task("Files", 8.0, 3.0);
6522 mofn.add_task("Chunks", 128.0, 128.0);
6523 console.print(&mofn);
6524
6525 let mut download = Progress::new().columns(vec![
6527 ProgressColumn::Description,
6528 ProgressColumn::Bar,
6529 ProgressColumn::Download,
6530 ]);
6531 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6532 console.print(&download);
6533
6534 demo::section(&console, delay, "json");
6536 if let Ok(json) = Json::new(
6537 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6538 ) {
6539 console.print(&json);
6540 }
6541
6542 demo::section(&console, delay, "pretty");
6544 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6545 console.print(&Pretty::new(&value));
6546
6547 demo::section(&console, delay, "log");
6549 let log = LogRender::new().show_level(true);
6550 for (level, message) in [
6551 (LogLevel::Info, "server started on port 8080"),
6552 (LogLevel::Debug, "cache warm: 128 entries"),
6553 (LogLevel::Warn, "disk usage at 85%"),
6554 (LogLevel::Error, "connection reset by peer"),
6555 ] {
6556 console.print(&log.render(
6557 &console,
6558 Text::new(message),
6559 Some(Text::new("[12:00:00]")),
6560 level.text(),
6561 Some("main.rs"),
6562 Some(42),
6563 None,
6564 ));
6565 }
6566
6567 demo::section(&console, delay, "traceback");
6569 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6570 let err = std::io::Error::other(cause);
6571 console.print(&Traceback::new(&err));
6572
6573 demo::section(&console, delay, "bar (range)");
6575 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6576 console.print(&Bar::new(100.0, begin, end).width(48));
6577 }
6578
6579 demo::section(&console, delay, "markdown");
6581 console.print(&Markdown::new(
6582 "# 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---",
6583 ));
6584
6585 demo::section(&console, delay, "syntax");
6587 console.print(&Syntax::new(
6588 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
6589 "rust",
6590 ));
6591
6592 demo::section(&console, delay, "control codes");
6594 let show_escape = |label: &str, control: &Control| {
6595 let escaped = control
6597 .as_str()
6598 .replace('\x1b', "\\x1b")
6599 .replace('\x07', "\\a")
6600 .replace('\r', "\\r");
6601 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
6602 };
6603 show_escape("clear screen", &Control::clear());
6604 show_escape("home", &Control::home());
6605 show_escape("move (2,-1)", &Control::move_(2, -1));
6606 show_escape("move_to (3,4)", &Control::move_to(3, 4));
6607 show_escape("hide cursor", &Control::show_cursor(false));
6608 show_escape("bell", &Control::bell());
6609 let live_render = LiveRender::new(text("a\nb\nc"));
6611 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
6613 let live_console = Console::builder()
6616 .force_terminal(true)
6617 .color_system(Some(ColorSystem::Truecolor))
6618 .width(20)
6619 .build();
6620 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
6621 live.start();
6622 live.update(text("frame two"));
6623 live.stop();
6624 let stream = String::from_utf8_lossy(live.writer())
6625 .replace('\x1b', "\\x1b")
6626 .replace('\r', "\\r")
6627 .replace('\n', "\\n");
6628 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
6629
6630 demo::section(&console, delay, "ansi decoder");
6632 let raw =
6633 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
6634 console.print(&Text::new(format!(
6635 " input: {}",
6636 raw.replace('\x1b', "\\x1b")
6637 )));
6638 let mut decoder = rich::AnsiDecoder::new();
6639 for line in decoder.decode(raw) {
6640 console.print(&Text::new(" output: ").append_text(&line));
6641 }
6642
6643 demo::section(&console, delay, "capture · export_text");
6645 let plain = console.export_text(|c| {
6646 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
6647 });
6648 console.print(&Text::new(" export_text (styles stripped):"));
6649 for line in plain.lines() {
6650 console.print(&Text::new(format!(" {line}")));
6651 }
6652 let html_rule = Style::parse("bold red")
6654 .unwrap()
6655 .get_html_style(&DEFAULT_TERMINAL_THEME);
6656 console.print(&Text::new(format!(
6657 " export_html: [bold red] → <span style=\"{html_rule}\">"
6658 )));
6659
6660 demo::section(&console, delay, "layout");
6663 let panel_leaf = |title: &str, body: &str| {
6664 Layout::with_renderable(Box::new(
6665 Panel::new(text(body))
6666 .box_set(SQUARE)
6667 .title(title.to_string()),
6668 ))
6669 };
6670 let mut header = Layout::new();
6671 header.split_row(vec![
6672 panel_leaf("left", "pane A"),
6673 panel_leaf("right", "pane B"),
6674 ]);
6675 let mut root = Layout::new();
6676 root.split_column(vec![
6677 header,
6678 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
6679 ]);
6680 let grid = Console::builder()
6681 .force_terminal(true)
6682 .color_system(Some(ColorSystem::Truecolor))
6683 .width(48)
6684 .height(7)
6685 .build();
6686 for line in grid.export_text(|c| c.print(&root)).lines() {
6687 console.print(&Text::new(line));
6688 }
6689
6690 demo::section(&console, delay, "spinner (frames)");
6692 for name in ["dots", "line", "arrow", "simpleDots"] {
6693 let spinner = Spinner::new(name);
6694 let frames: String = (0..6)
6695 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
6696 .collect::<Vec<_>>()
6697 .join(" ");
6698 console.print_str(&format!(" {name:>11}: {frames}"));
6699 }
6700 let status = Status::new("Loading…").renderable().render(0.0);
6702 console.print(&Text::new(" status: ").append_text(&status));
6703
6704 demo::section(&console, delay, "csv");
6706 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
6707 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
6708 console.print(&table);
6709 }
6710
6711 demo::section(&console, delay, "filesize");
6713 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
6714 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
6715 }
6716 console.print(&Rule::line());
6717 console
6718}
6719
6720#[cfg(test)]
6721mod tests {
6722 use super::*;
6723 use rich::ColorSystem;
6724
6725 const MODE_FLAGS: &[&str] = &[
6727 "--print",
6728 "-p",
6729 "--markdown",
6730 "-m",
6731 "--json",
6732 "-j",
6733 "-J",
6734 "--syntax",
6735 "-x",
6736 "--csv",
6737 "--ipynb",
6738 "--rst",
6739 "--jsonl",
6740 "--ndjson",
6741 "--log",
6742 "--rule",
6743 "-u",
6744 "--image",
6745 "--gif",
6746 "--diff",
6747 "--inspect",
6748 "--ansi-explain",
6749 ];
6750
6751 #[test]
6754 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
6755 for flag in MODE_FLAGS {
6756 assert!(mode_flag_alias(flag).is_some(), "{flag}");
6757 }
6758 let claimed = |args: &[String]| {
6759 let mut words = Vec::new();
6760 if plugins::requested(args) {
6761 words.push("plugins");
6762 }
6763 if doctor::requested(args) {
6764 words.push("doctor");
6765 }
6766 #[cfg(feature = "art")]
6767 if micro::requested(args) {
6768 words.push("micro");
6769 }
6770 #[cfg(feature = "record")]
6771 if record::requested(args) {
6772 words.push("record");
6773 }
6774 #[cfg(feature = "interact")]
6775 words.extend(interactive::requested(args));
6776 if tools::bench_dispatch(args).unwrap_or(true) {
6777 words.push("bench");
6778 }
6779 let roots = ConfigRoots {
6780 home: None,
6781 cwd: std::env::temp_dir(),
6782 no_color_env: false,
6783 };
6784 if !matches!(config::inspect(args, &roots), Ok(None)) {
6785 words.push("config");
6786 }
6787 words
6788 };
6789 let words = [
6790 "plugins", "doctor", "record", "choose", "filter", "input", "confirm", "pager",
6791 "bench", "config",
6792 ];
6793 for word in words {
6794 let plain = claimed(&[word.to_string(), "--no-color".into()]);
6796 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
6797 for flag in MODE_FLAGS {
6798 let args = [flag.to_string(), word.to_string()];
6799 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
6800 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
6801 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
6802 }
6803 }
6804 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
6805 assert!(doctor::requested(&["doctor".into()]));
6806 }
6807
6808 #[cfg(feature = "art")]
6809 #[test]
6810 fn diff_threshold_uses_the_reports_tie_rounding() {
6811 assert!(!diff_threshold_exceeded(0.25, 0.24));
6813 assert!(diff_threshold_exceeded(0.75, 0.74));
6814 assert!(!diff_threshold_exceeded(0.75, 0.76));
6815 }
6816
6817 #[test]
6818 fn detects_urls() {
6819 assert!(is_url("http://example.com"));
6820 assert!(is_url("https://example.com/x"));
6821 assert!(is_url("HTTPS://example.com"));
6823 assert!(is_url("Http://example.com"));
6824 assert!(!is_url("example.com"));
6825 assert!(!is_url("./file.md"));
6826 assert!(!is_url("-"));
6827 assert!(!is_url("")); assert!(!is_url("ht"));
6829 assert!(!is_url("ftp://example.com"));
6830 }
6831
6832 #[test]
6833 fn uninformative_extensions_defer_to_content_type() {
6834 assert!(is_uninformative_ext("txt"));
6835 assert!(is_uninformative_ext("log"));
6836 assert!(!is_uninformative_ext("py"));
6837 assert!(!is_uninformative_ext("rs"));
6838 }
6839
6840 #[test]
6841 fn resource_ext_uses_basename_without_query() {
6842 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
6843 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
6844 assert_eq!(
6845 resource_ext("https://x.com/c.py?raw=1").as_deref(),
6846 Some("py")
6847 );
6848 assert_eq!(resource_ext("https://api.example.com/data"), None);
6850 assert_eq!(resource_ext("https://x.com/"), None);
6851 assert_eq!(resource_ext("noext"), None);
6852 }
6853
6854 #[test]
6855 fn content_type_maps_mode_and_lexer() {
6856 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
6857 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
6858 assert_eq!(
6859 content_type_mode("application/json; charset=utf-8"),
6860 Some(Mode::Json)
6861 );
6862 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
6863 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
6865 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
6866 assert_eq!(content_type_lexer("text/plain"), None);
6867 }
6868
6869 #[test]
6870 fn join_source_handles_string_and_array() {
6871 use serde_json::json;
6872 assert_eq!(join_source(&json!("a\nb")), "a\nb");
6873 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
6875 assert_eq!(join_source(&json!(null)), "");
6876 }
6877
6878 #[test]
6879 fn ipynb_extension_auto_detects() {
6880 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
6881 }
6882
6883 #[test]
6884 fn io_label_builds_execution_count() {
6885 assert_eq!(
6887 io_label("In ", Some(1), "green", "#66ff00").plain(),
6888 "In [1]:"
6889 );
6890 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
6891 }
6892
6893 #[test]
6894 fn parse_padding_unpacks_like_upstream() {
6895 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
6896 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
6897 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
6898 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
6899 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
6901 }
6902
6903 #[test]
6904 fn parse_box_maps_names() {
6905 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
6906 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
6907 assert!(matches!(parse_box("none"), Ok(None)));
6909 assert!(parse_box("bogus").is_err());
6910 }
6911
6912 #[test]
6913 fn paging_policy_respects_viewport_and_redirection() {
6914 assert!(!should_page(true, true, false, 100, 24));
6915 assert!(!should_page(false, true, true, 23, 24));
6916 assert!(should_page(false, true, true, 24, 24));
6917 assert!(should_page(true, false, true, 1, 24));
6918 assert!(!should_page(false, false, true, 100, 24));
6919 }
6920
6921 #[test]
6922 fn worker_arguments_preserve_resources_named_like_commands() {
6923 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
6924 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
6925 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
6926 }
6927 }
6928
6929 #[test]
6930 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
6931 let args: Vec<String> = [
6932 "--batch",
6933 "--title",
6934 "--batch",
6935 "--jobs",
6936 "2",
6937 "json",
6938 "--export-html",
6939 "out.html",
6940 "--",
6941 "--pager",
6942 ]
6943 .into_iter()
6944 .map(str::to_string)
6945 .collect();
6946 assert_eq!(
6947 worker_args(&args),
6948 ["--no-config", "--title", "--batch", "--json"]
6949 );
6950 }
6951
6952 #[test]
6954 fn worker_arguments_keep_the_code_highlighter() {
6955 let args: Vec<String> = [
6956 "--batch",
6957 "--highlighter",
6958 "syntect",
6959 "--code-theme",
6960 "ansi_dark",
6961 "a.py",
6962 ]
6963 .into_iter()
6964 .map(str::to_string)
6965 .collect();
6966 assert_eq!(
6967 worker_args(&args),
6968 [
6969 "--no-config",
6970 "--highlighter",
6971 "syntect",
6972 "--code-theme",
6973 "ansi_dark"
6974 ]
6975 );
6976 }
6977
6978 #[test]
6979 fn parses_pager_flag() {
6980 let s = |v: &str| v.to_string();
6981 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
6982 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
6983 }
6984
6985 #[test]
6986 fn parses_watch_options_and_rejects_orphans() {
6987 let s = |v: &str| v.to_string();
6988 let cli = parse(&[
6989 s("--watch"),
6990 s("--watch-interval"),
6991 s("0.25"),
6992 s("--watch-cache"),
6993 s("file.md"),
6994 ])
6995 .unwrap()
6996 .unwrap();
6997 assert!(cli.watch);
6998 assert_eq!(cli.watch_interval, 0.25);
6999 assert!(cli.watch_cache);
7000 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7001 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7002 }
7003
7004 #[test]
7005 fn parses_multi_file_watch_options() {
7006 let s = |v: &str| v.to_string();
7007 let parse = parse_inner;
7008 let cli = parse(&[
7009 s("--watch"),
7010 s("--watch-debounce"),
7011 s("0.3"),
7012 s("--watch-poll"),
7013 s("--watch-exit-on-error"),
7014 s("a.md"),
7015 s("b.json"),
7016 ])
7017 .unwrap()
7018 .unwrap();
7019 assert_eq!(cli.resources, ["a.md", "b.json"]);
7020 assert_eq!(cli.watch_debounce, 0.3);
7021 assert!(cli.watch_poll && cli.watch_exit_on_error);
7022 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7023 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7024 assert!(!default.watch_poll && !default.watch_exit_on_error);
7025 for orphan in [
7026 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7027 vec![s("--watch-poll"), s("a.md")],
7028 vec![s("--watch-exit-on-error"), s("a.md")],
7029 ] {
7030 assert!(parse(&orphan)
7031 .err()
7032 .unwrap()
7033 .contains("only has an effect with --watch"));
7034 }
7035 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7036 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7037 }
7038 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7040 }
7041
7042 #[test]
7043 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7044 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7045 let _ = std::fs::remove_file(&path);
7046 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7047 std::fs::write(&path, "first").unwrap();
7048 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7049 std::fs::write(&path, "second content").unwrap();
7050 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7051 let _ = std::fs::remove_file(&path);
7052 assert_eq!(missing, "missing");
7053 assert_ne!(first, missing);
7054 assert_ne!(first, second);
7055 }
7056
7057 #[test]
7058 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7059 let s = |v: &str| v.to_string();
7060 let cli = parse(&[
7061 s("--no-config"),
7062 s("--batch"),
7063 s("--jobs"),
7064 s("3"),
7065 s("-o"),
7066 s("out.html"),
7067 s("--continue-on-error"),
7068 s("--collision"),
7069 s("suffix"),
7070 s("input"),
7071 ])
7072 .unwrap()
7073 .unwrap();
7074 assert!(cli.batch);
7075 assert_eq!(cli.jobs, 3);
7076 assert!(cli.continue_on_error);
7077 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7078 }
7079
7080 #[test]
7081 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7082 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7083 let _ = std::fs::remove_dir_all(&root);
7084 let nested = root.join("nested");
7085 std::fs::create_dir_all(&nested).unwrap();
7086 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7087 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7088
7089 let root_arg = root.to_string_lossy().into_owned();
7090 let glob_arg = root
7091 .join("*richbatchonly.md")
7092 .to_string_lossy()
7093 .into_owned();
7094 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7095 assert_eq!(inputs.len(), 2);
7096 assert!(inputs[0].ends_with("richbatchnested.md"));
7097 assert!(inputs[1].ends_with("richbatchonly.md"));
7098
7099 std::fs::remove_dir_all(root).unwrap();
7100 }
7101
7102 #[test]
7103 fn batch_output_suffixes_are_deterministic_for_collisions() {
7104 let inputs = ["one.md", "two.md"];
7105 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7106 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7107 assert_eq!(first, "one.html");
7108 assert_eq!(second, "two.html");
7109 assert_eq!(
7110 Path::new("one.html")
7111 .with_file_name("one-2.html")
7112 .to_string_lossy(),
7113 "one-2.html"
7114 );
7115 }
7116
7117 #[test]
7118 fn config_profiles_support_profiles_section_and_source_errors() {
7119 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7120 std::fs::create_dir_all(&root).unwrap();
7121 let config = root.join("config.toml");
7122 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7123 let args = vec![
7124 "--config".to_string(),
7125 config.to_string_lossy().into_owned(),
7126 "--profile".to_string(),
7127 "ci".to_string(),
7128 ];
7129 let merged = config_args(
7130 &args,
7131 &ConfigRoots {
7132 home: None,
7133 cwd: root.clone(),
7134 no_color_env: false,
7135 },
7136 )
7137 .unwrap();
7138 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7139 assert!(merged
7140 .windows(2)
7141 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7142
7143 let bad = root.join("bad.toml");
7144 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7145 let error = config_args(
7146 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7147 &ConfigRoots {
7148 home: None,
7149 cwd: root.clone(),
7150 no_color_env: false,
7151 },
7152 )
7153 .unwrap_err();
7154 assert!(error.contains("config "));
7155 assert!(error.contains("unknown key"));
7156 std::fs::remove_dir_all(root).unwrap();
7157 }
7158
7159 #[test]
7160 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7161 let s = |v: &str| v.to_string();
7162 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7163 .err()
7164 .unwrap();
7165 assert!(error.contains("--batch cannot be combined with --diff"));
7166 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7167 .err()
7168 .unwrap();
7169 assert!(error.contains("--batch cannot be combined with --gif"));
7170 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7172 .err()
7173 .unwrap();
7174 assert!(error.contains("--pager cannot be combined with --batch"));
7175 }
7176
7177 #[test]
7178 fn line_counts_follow_python_splitlines() {
7179 assert_eq!(python_line_count(""), 0);
7180 assert_eq!(python_line_count("a"), 1);
7181 assert_eq!(python_line_count("a\n"), 1);
7182 assert_eq!(python_line_count("a\n\n"), 2);
7183 assert_eq!(python_line_count("a\r\nb"), 2);
7184 assert_eq!(
7185 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7186 8
7187 );
7188 }
7189
7190 #[test]
7191 fn glob_matches_metacharacters_rather_than_substrings() {
7192 assert!(glob_match("*.md", "notes.md"));
7193 assert!(!glob_match("*.md", "notes.md.bak"));
7195 assert!(glob_match("a?c.md", "abc.md"));
7196 assert!(!glob_match("a?c.md", "ac.md"));
7197 assert!(glob_match("*", "anything"));
7198 assert!(glob_match("a*b*c", "axxbyyc"));
7199 assert!(!glob_match("a*b*c", "axxbyy"));
7200 }
7201
7202 #[test]
7203 fn batch_expansion_skips_symlinked_directories() {
7204 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7205 let _ = std::fs::remove_dir_all(&root);
7206 std::fs::create_dir_all(&root).unwrap();
7207 std::fs::write(root.join("a.md"), "# a").unwrap();
7208 #[cfg(unix)]
7210 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7211 #[cfg(windows)]
7212 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7213 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7214 if linked {
7215 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7216 }
7217 assert!(inputs[0].ends_with("a.md"));
7218 let _ = std::fs::remove_dir_all(&root);
7219 }
7220
7221 #[test]
7222 fn subcommand_mode_wins_over_config_mode_default() {
7223 let s = |v: &str| v.to_string();
7224 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7225 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7226 assert!(!selects_mode_explicitly(&[
7228 s("--title"),
7229 s("json"),
7230 s("file")
7231 ]));
7232 assert!(!selects_mode_explicitly(&[s("file.md")]));
7233
7234 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7235 let _ = std::fs::remove_dir_all(&root);
7236 std::fs::create_dir_all(&root).unwrap();
7237 let config = root.join("config.toml");
7238 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7239 let merged = config_args(
7240 &[
7241 "--config".to_string(),
7242 config.to_string_lossy().into_owned(),
7243 "json".to_string(),
7244 "file.json".to_string(),
7245 ],
7246 &ConfigRoots {
7247 home: None,
7248 cwd: root.clone(),
7249 no_color_env: false,
7250 },
7251 )
7252 .unwrap();
7253 assert!(
7254 !merged.iter().any(|arg| arg == "--markdown"),
7255 "config mode must not override an explicit subcommand: {merged:?}"
7256 );
7257 let _ = std::fs::remove_dir_all(&root);
7258 }
7259
7260 #[test]
7261 fn suffix_collisions_step_past_files_already_on_disk() {
7262 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7263 let _ = std::fs::remove_dir_all(&root);
7264 std::fs::create_dir_all(&root).unwrap();
7265 let target = root.join("out.html");
7266 std::fs::write(&target, "existing").unwrap();
7267 let target = target.to_string_lossy().into_owned();
7268
7269 let cli = parse(&[
7270 "--no-config".to_string(),
7271 "--batch".to_string(),
7272 "--collision".to_string(),
7273 "suffix".to_string(),
7274 "x".to_string(),
7275 ])
7276 .unwrap()
7277 .unwrap();
7278 let mut taken = std::collections::BTreeSet::new();
7279 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7280 assert!(
7281 first.ends_with("out-2.html"),
7282 "existing file must be kept: {first}"
7283 );
7284 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7285 assert!(second.ends_with("out-3.html"));
7286
7287 let mut cli_error = cli.clone();
7289 cli_error.collision = CollisionPolicy::Error;
7290 let mut taken = std::collections::BTreeSet::new();
7291 let (class, message) =
7292 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7293 assert_eq!(class, ExitClass::Input);
7294 assert!(message.contains("--overwrite"));
7295
7296 let mut cli_overwrite = cli.clone();
7298 cli_overwrite.overwrite = true;
7299 let mut taken = std::collections::BTreeSet::new();
7300 assert_eq!(
7301 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7302 target
7303 );
7304 let _ = std::fs::remove_dir_all(&root);
7305 }
7306
7307 #[test]
7308 fn batch_outputs_keep_their_export_kind() {
7309 let outputs = BatchOutputs {
7311 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7312 ..BatchOutputs::default()
7313 };
7314 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7315 assert!(outputs.svg.is_none());
7316 }
7317
7318 #[test]
7319 fn parses_export_flags() {
7320 let s = |v: &str| v.to_string();
7321 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7323 .unwrap()
7324 .unwrap();
7325 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7326 assert!(cli.export_html.is_none());
7327 assert_eq!(cli.resource.as_deref(), Some("x"));
7328
7329 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7330 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7331
7332 let cli = parse(&[
7334 s("--export-html"),
7335 s("a.html"),
7336 s("--export-svg"),
7337 s("b.svg"),
7338 s("x"),
7339 ])
7340 .unwrap()
7341 .unwrap();
7342 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7343 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7344
7345 assert!(parse(&[s("--export-html")]).is_err());
7347 }
7349
7350 #[test]
7351 fn is_number_matches_pattern() {
7352 for ok in [
7357 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7358 ] {
7359 assert!(is_number(ok), "{ok:?} should be numeric");
7360 }
7361 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7362 assert!(!is_number(no), "{no:?} should not be numeric");
7363 }
7364 }
7365
7366 #[test]
7367 fn render_csv_matches_upstream() {
7368 let table = build_csv_table(
7372 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7373 Some(','),
7374 None,
7375 None,
7376 (None, None),
7377 false,
7378 )
7379 .expect("the sniffer reads this one");
7380 let out = Console::builder()
7381 .force_terminal(true)
7382 .color_system(Some(ColorSystem::Truecolor))
7383 .width(40)
7384 .no_color(false)
7385 .build()
7386 .render_to_string(&table);
7387 let expected = concat!(
7388 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7389 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7390 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7391 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7392 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7393 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7394 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7395 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7396 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7397 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7398 );
7399 assert_eq!(out, expected);
7400 }
7401}